feat: app deployment v2 (#39829)

Co-authored-by: zhangx1n <zhangxin@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Wu Tianwei
2026-08-12 01:55:36 +00:00
committed by GitHub
co-authored by zhangx1n autofix-ci[bot]
parent ef8544b173
commit 7aba539e82
542 changed files with 29450 additions and 13824 deletions
+3
View File
@@ -315,6 +315,9 @@ class WorkflowResponse(ResponseModel):
) )
hash: str = Field(validation_alias=AliasChoices("unique_hash", "hash")) hash: str = Field(validation_alias=AliasChoices("unique_hash", "hash"))
version: str version: str
# NULL for drafts and for versions published before numbering was introduced; those
# render as "Untitled Version" instead of `#N`. Never 0, so clients must test for null.
version_number: int | None = None
marked_name: str marked_name: str
marked_comment: str marked_comment: str
created_by: SimpleAccount | None = Field( created_by: SimpleAccount | None = Field(
+48 -9
View File
@@ -5,13 +5,15 @@ to attribute the created app; workspace/membership validation is done by the
Go admin-api caller. Go admin-api caller.
""" """
from uuid import UUID
from flask import request from flask import request
from flask_restx import Resource from flask_restx import Resource
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ValidationError, field_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from controllers.common.schema import register_schema_model from controllers.common.schema import query_params_from_model, register_schema_model
from controllers.console.wraps import setup_required from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only from controllers.inner_api.wraps import enterprise_inner_api_only
@@ -20,6 +22,7 @@ from models import Account, App
from models.account import AccountStatus from models.account import AccountStatus
from services.app_dsl_service import AppDslService from services.app_dsl_service import AppDslService
from services.entities.dsl_entities import ImportMode, ImportStatus from services.entities.dsl_entities import ImportMode, ImportStatus
from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError
class InnerAppDSLImportPayload(BaseModel): class InnerAppDSLImportPayload(BaseModel):
@@ -29,6 +32,18 @@ class InnerAppDSLImportPayload(BaseModel):
description: str | None = Field(default=None, description="Override app description from DSL") description: str | None = Field(default=None, description="Override app description from DSL")
class EnterpriseAppDSLExportQuery(BaseModel):
include_secret: bool = Field(default=False, description="Whether to include secret values in the exported DSL")
workflow_id: UUID | None = Field(default=None, description="Published workflow version ID to export")
@field_validator("include_secret", mode="before")
@classmethod
def parse_include_secret(cls, value: object) -> bool:
if isinstance(value, str):
return value.lower() == "true"
return bool(value)
register_schema_model(inner_api_ns, InnerAppDSLImportPayload) register_schema_model(inner_api_ns, InnerAppDSLImportPayload)
@@ -82,24 +97,48 @@ class EnterpriseAppDSLExport(Resource):
@enterprise_inner_api_only @enterprise_inner_api_only
@inner_api_ns.doc( @inner_api_ns.doc(
"enterprise_app_dsl_export", "enterprise_app_dsl_export",
params=query_params_from_model(EnterpriseAppDSLExportQuery),
responses={ responses={
200: "Export successful", 200: "Export successful",
404: "App not found", 400: "Invalid workflow ID or unpublished workflow version",
404: "App or workflow version not found",
}, },
) )
def get(self, app_id: str): def get(self, app_id: str):
"""Export an app's DSL as YAML.""" """Export an app's DSL as YAML."""
include_secret = request.args.get("include_secret", "false").lower() == "true" try:
query = EnterpriseAppDSLExportQuery.model_validate(request.args.to_dict(flat=True))
except ValidationError:
return {
"code": "invalid_workflow_id",
"message": "workflow_id must be a valid UUID",
"status": 400,
}, 400
workflow_id = str(query.workflow_id) if query.workflow_id else None
app_model = db.session.get(App, app_id) app_model = db.session.get(App, app_id)
if not app_model: if not app_model:
return {"message": "app not found"}, 404 return {"message": "app not found"}, 404
data = AppDslService.export_dsl( if not workflow_id:
app_model=app_model, data = AppDslService.export_dsl(
session=db.session(), app_model=app_model,
include_secret=include_secret, session=db.session(),
) include_secret=query.include_secret,
)
else:
try:
data = AppDslService.export_dsl(
app_model=app_model,
session=db.session(),
include_secret=query.include_secret,
workflow_id=workflow_id,
)
except WorkflowNotFoundError as exc:
return {"code": "workflow_version_not_found", "message": str(exc), "status": 404}, 404
except IsDraftWorkflowError as exc:
return {"code": "workflow_version_not_published", "message": str(exc), "status": 400}, 400
return {"data": data}, 200 return {"data": data}, 200
@@ -0,0 +1,60 @@
"""add workflow version number
Revision ID: a1c7f4e9b3d2
Revises: e4708db55c1d
Create Date: 2026-08-05 10:30:00.000000
Introduces user-facing workflow version numbers (`#N`), unique and monotonically
increasing per app. `workflow_version_counters` holds one row per app with the
highest number handed out so far, so numbers are never reused when a published
version is deleted.
DDL only. Versions published before this revision keep `version_number` NULL and
continue to render as "Untitled Version"; numbering starts at #1 on the first
publish after the upgrade.
"""
import sqlalchemy as sa
from alembic import op
import models as models
# revision identifiers, used by Alembic.
revision = "a1c7f4e9b3d2"
down_revision = "e4708db55c1d"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"workflow_version_counters",
sa.Column("app_id", models.types.StringUUID(), nullable=False),
sa.Column("last_version_number", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("app_id", name="workflow_version_counter_pkey"),
)
with op.batch_alter_table("workflows", schema=None) as batch_op:
batch_op.add_column(sa.Column("version_number", sa.Integer(), nullable=True))
# Excluding NULLs keeps the index off every pre-existing version row. The
# partial-WHERE clause is PG-only (SQLAlchemy drops the kwarg on MySQL →
# plain unique index); both dialects treat NULLs as distinct, so unnumbered
# rows stay unconstrained either way.
op.create_index(
"workflow_app_version_number_idx",
"workflows",
["app_id", "version_number"],
unique=True,
postgresql_where=sa.text("version_number IS NOT NULL"),
)
def downgrade():
op.drop_index("workflow_app_version_number_idx", table_name="workflows")
with op.batch_alter_table("workflows", schema=None) as batch_op:
batch_op.drop_column("version_number")
op.drop_table("workflow_version_counters")
+2
View File
@@ -148,6 +148,7 @@ from .workflow import (
WorkflowRun, WorkflowRun,
WorkflowRunArchiveBundle, WorkflowRunArchiveBundle,
WorkflowType, WorkflowType,
WorkflowVersionCounter,
resolve_workflow_kind, resolve_workflow_kind,
) )
@@ -293,5 +294,6 @@ __all__ = [
"WorkflowToolProvider", "WorkflowToolProvider",
"WorkflowTriggerStatus", "WorkflowTriggerStatus",
"WorkflowType", "WorkflowType",
"WorkflowVersionCounter",
"resolve_workflow_kind", "resolve_workflow_kind",
] ]
+30
View File
@@ -211,6 +211,13 @@ class Workflow(Base): # bug
__table_args__ = ( __table_args__ = (
sa.PrimaryKeyConstraint("id", name="workflow_pkey"), sa.PrimaryKeyConstraint("id", name="workflow_pkey"),
sa.Index("workflow_version_idx", "tenant_id", "app_id", "version"), sa.Index("workflow_version_idx", "tenant_id", "app_id", "version"),
sa.Index(
"workflow_app_version_number_idx",
"app_id",
"version_number",
unique=True,
postgresql_where=sa.text("version_number IS NOT NULL"),
),
) )
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4())) id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
@@ -224,6 +231,9 @@ class Workflow(Base): # bug
server_default=sa.text("'standard'"), server_default=sa.text("'standard'"),
) )
version: Mapped[str] = mapped_column(String(255), nullable=False) version: Mapped[str] = mapped_column(String(255), nullable=False)
# User-facing version number, unique and monotonically increasing within an app, displayed as `#N`.
# NULL for draft workflows and for versions published before numbering was introduced.
version_number: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None)
marked_name: Mapped[str] = mapped_column(String(255), default="", server_default="") marked_name: Mapped[str] = mapped_column(String(255), default="", server_default="")
marked_comment: Mapped[str] = mapped_column(String(255), default="", server_default="") marked_comment: Mapped[str] = mapped_column(String(255), default="", server_default="")
graph: Mapped[str] = mapped_column(LongText) graph: Mapped[str] = mapped_column(LongText)
@@ -265,6 +275,7 @@ class Workflow(Base): # bug
marked_name: str = "", marked_name: str = "",
marked_comment: str = "", marked_comment: str = "",
kind: str | None = WorkflowKind.STANDARD.value, kind: str | None = WorkflowKind.STANDARD.value,
version_number: int | None = None,
) -> "Workflow": ) -> "Workflow":
workflow = Workflow() workflow = Workflow()
workflow.id = str(uuid4()) workflow.id = str(uuid4())
@@ -273,6 +284,7 @@ class Workflow(Base): # bug
workflow.type = WorkflowType(type) workflow.type = WorkflowType(type)
workflow.kind = resolve_workflow_kind(kind) workflow.kind = resolve_workflow_kind(kind)
workflow.version = version workflow.version = version
workflow.version_number = version_number
workflow.graph = graph workflow.graph = graph
workflow.features = features workflow.features = features
workflow.created_by = created_by workflow.created_by = created_by
@@ -735,6 +747,24 @@ class Workflow(Base): # bug
return str(d) return str(d)
class WorkflowVersionCounter(Base):
"""Monotonic per-app allocator for `Workflow.version_number`.
One row per app, holding the highest number handed out so far. Numbers are never
reused, so deleting a published version does not free its number.
`app_id` mirrors `Workflow.app_id`, which is polymorphic: it holds an app id, a
pipeline id or a snippet id depending on the workflow kind. UUID uniqueness across
those tables is why no owner-type column is needed here.
"""
__tablename__ = "workflow_version_counters"
__table_args__ = (sa.PrimaryKeyConstraint("app_id", name="workflow_version_counter_pkey"),)
app_id: Mapped[str] = mapped_column(StringUUID)
last_version_number: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
class WorkflowRunDict(TypedDict): class WorkflowRunDict(TypedDict):
id: str id: str
tenant_id: str tenant_id: str
+2
View File
@@ -22097,6 +22097,7 @@ Query parameters for listing snippet published workflows.
| updated_at | integer | | Yes | | updated_at | integer | | Yes |
| updated_by | [SimpleAccountResponse](#simpleaccountresponse) | | No | | updated_by | [SimpleAccountResponse](#simpleaccountresponse) | | No |
| version | string | | Yes | | version | string | | Yes |
| version_number | integer | | No |
#### StarredAppListQuery #### StarredAppListQuery
@@ -24112,6 +24113,7 @@ tenant's default model. The underlying generator never raises — an empty
| updated_at | integer | | Yes | | updated_at | integer | | Yes |
| updated_by | [SimpleAccountResponse](#simpleaccountresponse) | | No | | updated_by | [SimpleAccountResponse](#simpleaccountresponse) | | No |
| version | string | | Yes | | version | string | | Yes |
| version_number | integer | | No |
#### WorkflowRestoreResponse #### WorkflowRestoreResponse
+6
View File
@@ -652,6 +652,9 @@ class AppDslService:
:param app_model: App instance :param app_model: App instance
:param session: Database session used to load export data :param session: Database session used to load export data
:param include_secret: Whether include secret variable :param include_secret: Whether include secret variable
:param workflow_id: Optional published workflow version to export
:raises WorkflowNotFoundError: If the selected workflow version does not exist
:raises IsDraftWorkflowError: If the selected workflow is a draft
:return: :return:
""" """
app_mode = AppMode.value_of(app_model.mode) app_mode = AppMode.value_of(app_model.mode)
@@ -711,10 +714,13 @@ class AppDslService:
Append workflow export data Append workflow export data
:param export_data: export data :param export_data: export data
:param app_model: App instance :param app_model: App instance
:param workflow_id: Optional published workflow version to export
""" """
workflow_service = WorkflowService() workflow_service = WorkflowService()
workflow = workflow_service.get_draft_workflow(app_model, workflow_id, session=session) workflow = workflow_service.get_draft_workflow(app_model, workflow_id, session=session)
if not workflow: if not workflow:
if workflow_id:
raise WorkflowNotFoundError(f"Workflow version not found. Workflow ID: {workflow_id}.")
raise WorkflowNotFoundError("Missing draft workflow configuration, please check.") raise WorkflowNotFoundError("Missing draft workflow configuration, please check.")
workflow_dict = workflow.to_dict(include_secret=include_secret) workflow_dict = workflow.to_dict(include_secret=include_secret)
+3
View File
@@ -397,6 +397,7 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [
"app.acl.import_export_dsl", "app.acl.import_export_dsl",
"app.acl.delete", "app.acl.delete",
"app.acl.release_and_version", "app.acl.release_and_version",
"app.acl.deploy",
"app.acl.monitor", "app.acl.monitor",
"app.acl.access_config", "app.acl.access_config",
"app.acl.tracing_config", "app.acl.tracing_config",
@@ -411,6 +412,7 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [
"app.acl.import_export_dsl", "app.acl.import_export_dsl",
"app.acl.delete", "app.acl.delete",
"app.acl.release_and_version", "app.acl.release_and_version",
"app.acl.deploy",
"app.acl.monitor", "app.acl.monitor",
"app.acl.access_config", "app.acl.access_config",
"app.acl.access_config", "app.acl.access_config",
@@ -426,6 +428,7 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [
"app.acl.import_export_dsl", "app.acl.import_export_dsl",
"app.acl.delete", "app.acl.delete",
"app.acl.release_and_version", "app.acl.release_and_version",
"app.acl.deploy",
"app.acl.monitor", "app.acl.monitor",
"app.acl.log_and_annotation", "app.acl.log_and_annotation",
"app.acl.access_config", "app.acl.access_config",
+12 -1
View File
@@ -142,6 +142,7 @@ HumanInputNode = _DebugHumanInputNode
from services.human_input_service import HumanInputService from services.human_input_service import HumanInputService
from services.workflow.workflow_converter import WorkflowConverter from services.workflow.workflow_converter import WorkflowConverter
from services.workflow_ref_service import WorkflowRef from services.workflow_ref_service import WorkflowRef
from services.workflow_version_number_service import allocate_version_number
from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
from .human_input_delivery_test_service import ( from .human_input_delivery_test_service import (
@@ -356,7 +357,16 @@ class WorkflowService:
stmt = ( stmt = (
select(Workflow) select(Workflow)
.where(Workflow.app_id == app_model.id) .where(Workflow.app_id == app_model.id)
.order_by(Workflow.version.desc()) # The draft leads the list; its `created_at` is the app's creation time, so it would
# otherwise sort last. Published versions then order by publish time: `version` is a
# stringified timestamp whose microseconds are omitted when zero, so ordering by it
# misplaces versions across second boundaries, and `version_number` is NULL for
# versions published before numbering was introduced.
.order_by(
(Workflow.version == Workflow.VERSION_DRAFT).desc(),
Workflow.created_at.desc(),
Workflow.id.desc(),
)
.limit(limit + 1) .limit(limit + 1)
.offset((page - 1) * limit) .offset((page - 1) * limit)
) )
@@ -720,6 +730,7 @@ class WorkflowService:
app_id=app_model.id, app_id=app_model.id,
type=draft_workflow.type, type=draft_workflow.type,
version=Workflow.version_from_datetime(naive_utc_now()), version=Workflow.version_from_datetime(naive_utc_now()),
version_number=allocate_version_number(session=session, app_id=app_model.id),
graph=draft_workflow.graph, graph=draft_workflow.graph,
created_by=account.id, created_by=account.id,
environment_variables=draft_workflow.environment_variables, environment_variables=draft_workflow.environment_variables,
@@ -0,0 +1,59 @@
"""Allocation of user-facing workflow version numbers (`#N`).
Numbers are unique and monotonically increasing within an app, and are never
reused: `workflow_version_counters` keeps the highest number handed out so far,
so deleting a published version does not free its number.
The counter is keyed by `Workflow.app_id`, which is polymorphic it holds an app
id, a pipeline id or a snippet id depending on the workflow kind. UUID uniqueness
across those tables is why the same counter table serves all of them.
"""
from sqlalchemy import select
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from configs import dify_config
from models.workflow import WorkflowVersionCounter
def allocate_version_number(*, session: Session, app_id: str) -> int:
"""Reserve and return the next version number for `app_id`.
The upsert acquires a row lock that is held until the caller's transaction
commits, so concurrent publishes of the same app serialize and never receive
the same number. Callers must run inside a transaction; if it rolls back,
both the counter update and workflow creation roll back, leaving the number
available for the next successful publish.
"""
# Dialect-specific upsert, mirroring `workflow_draft_variable_service`: the
# ORM cannot express "insert or increment" and a read-then-write would race.
# PostgreSQL returns the new value inline; MySQL has no RETURNING, so the
# value is read back within the same transaction while the row is still
# locked by the upsert.
if dify_config.SQLALCHEMY_DATABASE_URI_SCHEME == "postgresql":
stmt = (
pg_insert(WorkflowVersionCounter)
.values(app_id=app_id, last_version_number=1)
.on_conflict_do_update(
index_elements=[WorkflowVersionCounter.app_id],
set_={"last_version_number": WorkflowVersionCounter.last_version_number + 1},
)
.returning(WorkflowVersionCounter.last_version_number)
)
version_number = session.scalar(stmt)
else:
insert_stmt = mysql_insert(WorkflowVersionCounter).values(app_id=app_id, last_version_number=1)
session.execute(
insert_stmt.on_duplicate_key_update( # type: ignore[attr-defined]
last_version_number=WorkflowVersionCounter.last_version_number + 1,
)
)
version_number = session.scalar(
select(WorkflowVersionCounter.last_version_number).where(WorkflowVersionCounter.app_id == app_id)
)
if version_number is None:
raise ValueError(f"Failed to allocate a workflow version number for app {app_id}.")
return version_number
@@ -1318,7 +1318,7 @@ class TestAppDslService:
with pytest.raises( with pytest.raises(
WorkflowNotFoundError, WorkflowNotFoundError,
match="Missing draft workflow configuration, please check.", match="Workflow version not found. Workflow ID:",
): ):
AppDslService.export_dsl( AppDslService.export_dsl(
app, include_secret=False, workflow_id=str(uuid4()), session=db_session_with_containers app, include_secret=False, workflow_id=str(uuid4()), session=db_session_with_containers
@@ -1004,7 +1004,12 @@ def test_app_detail_api_attaches_current_user_permission_keys(app, app_module, u
overrides=[ overrides=[
app_module.enterprise_rbac_service.ResourcePermissionKeys( app_module.enterprise_rbac_service.ResourcePermissionKeys(
resource_id="app-1", resource_id="app-1",
permission_keys=["app.acl.view_layout", "app.acl.edit", "app.acl.monitor"], permission_keys=[
"app.acl.view_layout",
"app.acl.edit",
"app.acl.deploy",
"app.acl.monitor",
],
) )
] ]
) )
@@ -1026,7 +1031,12 @@ def test_app_detail_api_attaches_current_user_permission_keys(app, app_module, u
get_app.assert_called_once_with(app_obj, session=unbound_session) get_app.assert_called_once_with(app_obj, session=unbound_session)
get_permissions.assert_called_once_with("tenant-1", "acct-1", app_id="app-1", session=unbound_session) get_permissions.assert_called_once_with("tenant-1", "acct-1", app_id="app-1", session=unbound_session)
assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit", "app.acl.monitor"] assert resp["permission_keys"] == [
"app.acl.view_layout",
"app.acl.edit",
"app.acl.deploy",
"app.acl.monitor",
]
def test_app_copy_api_attaches_permission_keys(app, app_module, sqlite_session: Session, sqlite_engine: Engine): def test_app_copy_api_attaches_permission_keys(app, app_module, sqlite_session: Session, sqlite_engine: Engine):
@@ -55,6 +55,27 @@ class TestCurrentIds:
assert rbac_mod._current_ids() == ("tenant-1", "acct-1") assert rbac_mod._current_ids() == ("tenant-1", "acct-1")
class TestMyPermissions:
def test_returns_app_deploy_permission(self, app):
permissions = rbac_mod.svc.MyPermissionsResponse(
app=rbac_mod.svc.ResourcePermissionSnapshot(
default_permission_keys=["app.acl.deploy"],
)
)
with (
app.test_request_context("/workspaces/current/rbac/my-permissions"),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch(
"controllers.console.workspace.rbac.svc.RBACService.MyPermissions.get",
return_value=permissions,
) as mock_get,
):
response = inspect.unwrap(rbac_mod.RBACMyPermissionsApi.get)(rbac_mod.RBACMyPermissionsApi())
assert response["app"]["default_permission_keys"] == ["app.acl.deploy"]
mock_get.assert_called_once()
class TestAccessMatrixAccountNames: class TestAccessMatrixAccountNames:
def test_hydrates_missing_account_names(self): def test_hydrates_missing_account_names(self):
items = [ items = [
@@ -28,6 +28,7 @@ from models import Account, App
from models.account import AccountStatus from models.account import AccountStatus
from models.model import AppMode, IconType from models.model import AppMode, IconType
from services.app_dsl_service import Import, ImportStatus from services.app_dsl_service import Import, ImportStatus
from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError
def _persist_app(session: Session) -> App: def _persist_app(session: Session) -> App:
@@ -235,6 +236,15 @@ class TestEnterpriseAppDSLExport:
Uses inspect.unwrap() to bypass auth/setup decorators. Uses inspect.unwrap() to bypass auth/setup decorators.
""" """
def test_export_documents_query_parameters(self):
params = EnterpriseAppDSLExport.get.__apidoc__["params"]
assert params["include_secret"]["in"] == "query"
assert params["include_secret"]["type"] == "boolean"
assert params["workflow_id"]["in"] == "query"
assert params["workflow_id"]["type"] == "string"
assert params["workflow_id"]["format"] == "uuid"
@pytest.fixture @pytest.fixture
def api_instance(self): def api_instance(self):
return EnterpriseAppDSLExport() return EnterpriseAppDSLExport()
@@ -293,6 +303,160 @@ class TestEnterpriseAppDSLExport:
assert call_kwargs["session"] is scoped_db() assert call_kwargs["session"] is scoped_db()
assert call_kwargs["include_secret"] is True assert call_kwargs["include_secret"] is True
@patch("controllers.inner_api.app.dsl.AppDslService")
def test_export_selected_workflow_forwards_canonical_uuid(
self,
mock_dsl_cls,
api_instance,
app: Flask,
sqlite_session: Session,
scoped_db,
):
app_model = _persist_app(sqlite_session)
mock_dsl_cls.export_dsl.return_value = "yaml-data"
workflow_id = "F1FD7266-56FC-45C7-9D81-A72CD5A1B4F6"
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context(f"?workflow_id={workflow_id}"):
body, status_code = unwrapped(api_instance, app_id=app_model.id)
assert status_code == 200
assert body["data"] == "yaml-data"
call_kwargs = mock_dsl_cls.export_dsl.call_args.kwargs
assert call_kwargs["app_model"].id == app_model.id
assert call_kwargs["session"] is scoped_db()
assert call_kwargs["include_secret"] is False
assert call_kwargs["workflow_id"] == "f1fd7266-56fc-45c7-9d81-a72cd5a1b4f6"
@patch("controllers.inner_api.app.dsl.AppDslService")
def test_export_selected_workflow_with_secret(
self,
mock_dsl_cls,
api_instance,
app: Flask,
sqlite_session: Session,
scoped_db,
):
app_model = _persist_app(sqlite_session)
mock_dsl_cls.export_dsl.return_value = "yaml-data"
workflow_id = "f1fd7266-56fc-45c7-9d81-a72cd5a1b4f6"
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context(f"?include_secret=true&workflow_id={workflow_id}"):
body, status_code = unwrapped(api_instance, app_id=app_model.id)
assert status_code == 200
assert body["data"] == "yaml-data"
call_kwargs = mock_dsl_cls.export_dsl.call_args.kwargs
assert call_kwargs["app_model"].id == app_model.id
assert call_kwargs["session"] is scoped_db()
assert call_kwargs["include_secret"] is True
assert call_kwargs["workflow_id"] == workflow_id
@patch("controllers.inner_api.app.dsl.AppDslService")
def test_export_rejects_invalid_selected_workflow_id(
self,
mock_dsl_cls,
api_instance,
app: Flask,
scoped_db,
):
assert scoped_db() is not None
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context("?workflow_id=not-a-uuid"):
body, status_code = unwrapped(api_instance, app_id=str(uuid4()))
assert status_code == 400
assert body == {
"code": "invalid_workflow_id",
"message": "workflow_id must be a valid UUID",
"status": 400,
}
mock_dsl_cls.export_dsl.assert_not_called()
@patch("controllers.inner_api.app.dsl.AppDslService")
def test_export_selected_missing_workflow_returns_404(
self,
mock_dsl_cls,
api_instance,
app: Flask,
sqlite_session: Session,
scoped_db,
):
app_model = _persist_app(sqlite_session)
mock_dsl_cls.export_dsl.side_effect = WorkflowNotFoundError("selected workflow not found")
workflow_id = "f1fd7266-56fc-45c7-9d81-a72cd5a1b4f6"
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context(f"?workflow_id={workflow_id}"):
body, status_code = unwrapped(api_instance, app_id=app_model.id)
assert status_code == 404
assert body == {
"code": "workflow_version_not_found",
"message": "selected workflow not found",
"status": 404,
}
call_kwargs = mock_dsl_cls.export_dsl.call_args.kwargs
assert call_kwargs["app_model"].id == app_model.id
assert call_kwargs["session"] is scoped_db()
assert call_kwargs["include_secret"] is False
assert call_kwargs["workflow_id"] == workflow_id
@patch("controllers.inner_api.app.dsl.AppDslService")
def test_export_selected_draft_workflow_returns_400(
self,
mock_dsl_cls,
api_instance,
app: Flask,
sqlite_session: Session,
scoped_db,
):
app_model = _persist_app(sqlite_session)
mock_dsl_cls.export_dsl.side_effect = IsDraftWorkflowError("selected workflow is a draft")
workflow_id = "f1fd7266-56fc-45c7-9d81-a72cd5a1b4f6"
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context(f"?workflow_id={workflow_id}"):
body, status_code = unwrapped(api_instance, app_id=app_model.id)
assert status_code == 400
assert body == {
"code": "workflow_version_not_published",
"message": "selected workflow is a draft",
"status": 400,
}
call_kwargs = mock_dsl_cls.export_dsl.call_args.kwargs
assert call_kwargs["app_model"].id == app_model.id
assert call_kwargs["session"] is scoped_db()
assert call_kwargs["include_secret"] is False
assert call_kwargs["workflow_id"] == workflow_id
@patch("controllers.inner_api.app.dsl.AppDslService")
def test_export_without_selected_workflow_preserves_workflow_error(
self,
mock_dsl_cls,
api_instance,
app: Flask,
sqlite_session: Session,
scoped_db,
):
app_model = _persist_app(sqlite_session)
mock_dsl_cls.export_dsl.side_effect = WorkflowNotFoundError(
"Missing draft workflow configuration, please check."
)
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context():
with pytest.raises(WorkflowNotFoundError, match="Missing draft workflow configuration"):
unwrapped(api_instance, app_id=app_model.id)
call_kwargs = mock_dsl_cls.export_dsl.call_args.kwargs
assert call_kwargs["app_model"].id == app_model.id
assert call_kwargs["session"] is scoped_db()
assert call_kwargs["include_secret"] is False
assert "workflow_id" not in call_kwargs
def test_export_app_not_found_returns_404(self, api_instance, app: Flask, scoped_db): def test_export_app_not_found_returns_404(self, api_instance, app: Flask, scoped_db):
assert scoped_db() is not None assert scoped_db() is not None
unwrapped = inspect.unwrap(api_instance.get) unwrapped = inspect.unwrap(api_instance.get)
@@ -636,6 +636,10 @@ class TestMyPermissions:
assert not any(key.startswith("billing.") for key in out.workspace.permission_keys) assert not any(key.startswith("billing.") for key in out.workspace.permission_keys)
if role == "editor": if role == "editor":
assert "app.acl.log_and_annotation" in out.app.default_permission_keys assert "app.acl.log_and_annotation" in out.app.default_permission_keys
if role in {"owner", "admin", "editor"}:
assert "app.acl.deploy" in out.app.default_permission_keys
else:
assert "app.acl.deploy" not in out.app.default_permission_keys
@pytest.mark.parametrize( @pytest.mark.parametrize(
("role", "expected_snippet_keys"), ("role", "expected_snippet_keys"),
@@ -15,6 +15,7 @@ from models.workflow import Workflow
from services.app_dsl_service import AppDslService, PendingData from services.app_dsl_service import AppDslService, PendingData
from services.entities.dsl_entities import ImportStatus from services.entities.dsl_entities import ImportStatus
from services.errors.account import NoPermissionError from services.errors.account import NoPermissionError
from services.errors.app import WorkflowNotFoundError
def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(monkeypatch: pytest.MonkeyPatch) -> None: def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -407,3 +408,20 @@ def test_import_app_reraises_permission_denial_instead_of_failed_result(
) )
assert not unbound_session.in_transaction() assert not unbound_session.in_transaction()
def test_append_workflow_export_data_reports_missing_selected_workflow(monkeypatch: pytest.MonkeyPatch) -> None:
workflow_id = "11111111-1111-4111-8111-111111111111"
workflow_service = Mock()
workflow_service.get_draft_workflow.return_value = None
monkeypatch.setattr("services.app_dsl_service.WorkflowService", Mock(return_value=workflow_service))
app = cast(App, SimpleNamespace(id="app-1", tenant_id="tenant-1"))
with pytest.raises(WorkflowNotFoundError, match=f"Workflow version not found. Workflow ID: {workflow_id}"):
AppDslService._append_workflow_export_data(
export_data={},
app_model=app,
include_secret=False,
session=Mock(),
workflow_id=workflow_id,
)
@@ -11,6 +11,7 @@ This test suite covers:
import json import json
import uuid import uuid
from datetime import datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast from typing import Any, cast
from unittest.mock import ANY, MagicMock, patch, sentinel from unittest.mock import ANY, MagicMock, patch, sentinel
@@ -356,6 +357,31 @@ class TestWorkflowService:
assert result is None assert result is None
@pytest.mark.parametrize(
("tenant_id", "app_id"),
[("other-tenant", "app-123"), ("tenant-456", "other-app")],
)
def test_get_published_workflow_by_id_rejects_foreign_workflow(
self,
tenant_id: str,
app_id: str,
workflow_service: WorkflowService,
sqlite_session: Session,
):
app = TestWorkflowAssociatedDataFactory.create_app()
workflow = TestWorkflowAssociatedDataFactory.create_workflow(
workflow_id="workflow-123",
tenant_id=tenant_id,
app_id=app_id,
version="v1",
)
sqlite_session.add(workflow)
sqlite_session.commit()
result = workflow_service.get_published_workflow_by_id(app, workflow.id, session=sqlite_session)
assert result is None
def test_get_published_workflow_success(self, workflow_service: WorkflowService, sqlite_session: Session): def test_get_published_workflow_success(self, workflow_service: WorkflowService, sqlite_session: Session):
"""Test get_published_workflow returns published workflow.""" """Test get_published_workflow returns published workflow."""
workflow_id = "workflow-123" workflow_id = "workflow-123"
@@ -1057,6 +1083,104 @@ class TestWorkflowService:
assert result.marked_comment == "Initial release" assert result.marked_comment == "Initial release"
assert retirement_candidates == set() assert retirement_candidates == set()
def test_publish_workflow_numbers_versions_from_one(
self, workflow_service: WorkflowService, sqlite_session: Session
):
"""
Test publish_workflow assigns an app-scoped version number starting at #1.
The number is what users see when a version carries no name, so it has to be
stable and monotonic per app rather than derived from list position.
"""
app = TestWorkflowAssociatedDataFactory.create_app()
account = TestWorkflowAssociatedDataFactory.create_account()
graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph()
draft = TestWorkflowAssociatedDataFactory.create_workflow(version=Workflow.VERSION_DRAFT, graph=graph)
sqlite_session.add(draft)
sqlite_session.commit()
with (
patch("services.workflow_service.app_published_workflow_was_updated"),
patch(
"services.workflow_service.dify_config.DEPLOYMENT_EDITION",
DeploymentEdition.COMMUNITY,
),
):
first, _ = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account)
second, _ = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account)
assert first.version_number == 1
assert second.version_number == 2
def test_publish_workflow_does_not_reuse_a_deleted_version_number(
self, workflow_service: WorkflowService, sqlite_session: Session
):
"""
Test version numbers are never handed out twice, even after a version is deleted.
Deployment records and audit logs refer to versions by number, so reusing one
would make two different workflows share an identity.
"""
app = TestWorkflowAssociatedDataFactory.create_app()
account = TestWorkflowAssociatedDataFactory.create_account()
graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph()
draft = TestWorkflowAssociatedDataFactory.create_workflow(version=Workflow.VERSION_DRAFT, graph=graph)
sqlite_session.add(draft)
sqlite_session.commit()
with (
patch("services.workflow_service.app_published_workflow_was_updated"),
patch(
"services.workflow_service.dify_config.DEPLOYMENT_EDITION",
DeploymentEdition.COMMUNITY,
),
):
published, _ = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account)
sqlite_session.flush()
sqlite_session.delete(published)
sqlite_session.flush()
republished, _ = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account)
assert republished.version_number == 2
def test_publish_workflow_numbers_each_app_independently(
self, workflow_service: WorkflowService, sqlite_session: Session
):
"""
Test the counter is scoped per app rather than global.
Every app starts its own sequence at #1; a busy neighbour must not advance it.
"""
account = TestWorkflowAssociatedDataFactory.create_account()
graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph()
published: list[Workflow] = []
for app_id in ("app-first", "app-second"):
app = TestWorkflowAssociatedDataFactory.create_app(app_id=app_id)
draft = TestWorkflowAssociatedDataFactory.create_workflow(
workflow_id=f"draft-{app_id}",
app_id=app_id,
version=Workflow.VERSION_DRAFT,
graph=graph,
)
sqlite_session.add(draft)
sqlite_session.commit()
with (
patch("services.workflow_service.app_published_workflow_was_updated"),
patch(
"services.workflow_service.dify_config.DEPLOYMENT_EDITION",
DeploymentEdition.COMMUNITY,
),
):
workflow, _ = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account)
published.append(workflow)
assert [workflow.version_number for workflow in published] == [1, 1]
def test_publish_workflow_no_draft_raises_error(self, workflow_service: WorkflowService, sqlite_session: Session): def test_publish_workflow_no_draft_raises_error(self, workflow_service: WorkflowService, sqlite_session: Session):
""" """
Test publish_workflow raises error when no draft exists. Test publish_workflow raises error when no draft exists.
@@ -1180,6 +1304,44 @@ class TestWorkflowService:
assert len(workflows) == 5 assert len(workflows) == 5
assert has_more is False assert has_more is False
def test_get_all_published_workflow_lists_the_draft_first(
self, workflow_service: WorkflowService, sqlite_session: Session
):
"""
Test the draft heads the version list no matter how old it is.
A draft is created together with its app and its `created_at` is never refreshed,
so ordering purely by publish time would put it last off the first page entirely
once the app has accumulated enough published versions.
"""
app = TestWorkflowAssociatedDataFactory.create_app(workflow_id="workflow-3")
app_created_at = datetime(2026, 1, 1)
sqlite_session.add(
TestWorkflowAssociatedDataFactory.create_workflow(
workflow_id="workflow-draft",
version=Workflow.VERSION_DRAFT,
created_at=app_created_at,
)
)
sqlite_session.add_all(
[
TestWorkflowAssociatedDataFactory.create_workflow(
workflow_id=f"workflow-{i}",
version=f"2026-02-0{i} 00:00:00",
created_at=app_created_at + timedelta(days=i),
)
for i in range(1, 4)
]
)
sqlite_session.commit()
workflows, _ = workflow_service.get_all_published_workflow(
session=sqlite_session, app_model=app, page=1, limit=2, user_id=None
)
assert [workflow.id for workflow in workflows] == ["workflow-draft", "workflow-3"]
def test_get_all_published_workflow_has_more(self, workflow_service: WorkflowService, sqlite_session: Session): def test_get_all_published_workflow_has_more(self, workflow_service: WorkflowService, sqlite_session: Session):
""" """
Test get_all_published_workflow indicates has_more when results exceed limit. Test get_all_published_workflow indicates has_more when results exceed limit.
+1 -2
View File
@@ -4,8 +4,7 @@ Feature: Manage Web App service
Scenario: Disable and restore a published workflow Web App Scenario: Disable and restore a published workflow Web App
Given I am signed in as the default E2E admin Given I am signed in as the default E2E admin
And a new runnable workflow app has been published And a new runnable workflow app has been published
When I navigate to the app overview page When I navigate to the app access point page
And I open the app information panel
Then the Web App should be in service Then the Web App should be in service
When an anonymous visitor opens the Web App When an anonymous visitor opens the Web App
Then the published workflow Web App should be accessible Then the published workflow Web App should be accessible
@@ -9,6 +9,8 @@ import {
import { SERVICE_API_RUNTIME_STEP_TIMEOUT_MS } from '../../agent-v2/support/service-api-sse' import { SERVICE_API_RUNTIME_STEP_TIMEOUT_MS } from '../../agent-v2/support/service-api-sse'
import { getCurrentAgentId, getServiceApiCard } from './access-point-helpers' import { getCurrentAgentId, getServiceApiCard } from './access-point-helpers'
const API_KEY_DIALOG_NAME = /^API Key$/i
async function createAgentApiKey(world: DifyWorld) { async function createAgentApiKey(world: DifyWorld) {
const agentId = getCurrentAgentId(world) const agentId = getCurrentAgentId(world)
const client = world.getConsoleClient() const client = world.getConsoleClient()
@@ -61,7 +63,7 @@ When('I open Agent v2 API key management', async function (this: DifyWorld) {
Then('Agent v2 API keys should not expose a secret by default', async function (this: DifyWorld) { Then('Agent v2 API keys should not expose a secret by default', async function (this: DifyWorld) {
const page = this.getPage() const page = this.getPage()
const dialog = page.getByRole('dialog', { name: /API Secret key/i }) const dialog = page.getByRole('dialog', { name: API_KEY_DIALOG_NAME })
const existingSecret = this.agentBuilder.accessPoint.generatedApiKey const existingSecret = this.agentBuilder.accessPoint.generatedApiKey
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
@@ -76,14 +78,14 @@ Then('Agent v2 API keys should not expose a secret by default', async function (
}) })
When('I create a new Agent v2 API key', async function (this: DifyWorld) { When('I create a new Agent v2 API key', async function (this: DifyWorld) {
const dialog = this.getPage().getByRole('dialog', { name: /API Secret key/i }) const dialog = this.getPage().getByRole('dialog', { name: API_KEY_DIALOG_NAME })
await dialog.getByRole('button', { name: 'Create new Secret key' }).click() await dialog.getByRole('button', { name: 'Create new Secret key' }).click()
}) })
Then('I should see the newly generated Agent v2 API key once', async function (this: DifyWorld) { Then('I should see the newly generated Agent v2 API key once', async function (this: DifyWorld) {
const generatedKeyDialog = this.getPage() const generatedKeyDialog = this.getPage()
.getByRole('dialog', { name: /API Secret key/i }) .getByRole('dialog', { name: API_KEY_DIALOG_NAME })
.last() .last()
const generatedKey = generatedKeyDialog.getByText(/^app-/) const generatedKey = generatedKeyDialog.getByText(/^app-/)
@@ -101,7 +103,7 @@ Then('I should see the newly generated Agent v2 API key once', async function (t
When('I copy the newly generated Agent v2 API key', async function (this: DifyWorld) { When('I copy the newly generated Agent v2 API key', async function (this: DifyWorld) {
const generatedKeyDialog = this.getPage() const generatedKeyDialog = this.getPage()
.getByRole('dialog', { name: /API Secret key/i }) .getByRole('dialog', { name: API_KEY_DIALOG_NAME })
.last() .last()
await generatedKeyDialog.getByLabel('Copy').first().click() await generatedKeyDialog.getByLabel('Copy').first().click()
@@ -111,7 +113,7 @@ Then(
'the newly generated Agent v2 API key should show it was copied', 'the newly generated Agent v2 API key should show it was copied',
async function (this: DifyWorld) { async function (this: DifyWorld) {
const generatedKeyDialog = this.getPage() const generatedKeyDialog = this.getPage()
.getByRole('dialog', { name: /API Secret key/i }) .getByRole('dialog', { name: API_KEY_DIALOG_NAME })
.last() .last()
await expect(generatedKeyDialog.getByLabel('Copied')).toBeVisible() await expect(generatedKeyDialog.getByLabel('Copied')).toBeVisible()
@@ -120,7 +122,7 @@ Then(
When('I close the newly generated Agent v2 API key', async function (this: DifyWorld) { When('I close the newly generated Agent v2 API key', async function (this: DifyWorld) {
const page = this.getPage() const page = this.getPage()
const generatedKeyDialog = page.getByRole('dialog', { name: /API Secret key/i }).last() const generatedKeyDialog = page.getByRole('dialog', { name: API_KEY_DIALOG_NAME }).last()
await generatedKeyDialog.getByRole('button', { name: 'OK' }).click() await generatedKeyDialog.getByRole('button', { name: 'OK' }).click()
await expect(page.getByText('Keep this key in a secure and accessible place.')).not.toBeVisible() await expect(page.getByText('Keep this key in a secure and accessible place.')).not.toBeVisible()
@@ -132,7 +134,7 @@ Then(
const fullSecret = this.agentBuilder.accessPoint.generatedApiKey const fullSecret = this.agentBuilder.accessPoint.generatedApiKey
if (!fullSecret) throw new Error('No generated Agent v2 API key found.') if (!fullSecret) throw new Error('No generated Agent v2 API key found.')
const apiKeyDialog = this.getPage().getByRole('dialog', { name: /API Secret key/i }) const apiKeyDialog = this.getPage().getByRole('dialog', { name: API_KEY_DIALOG_NAME })
await expect(apiKeyDialog).toBeVisible() await expect(apiKeyDialog).toBeVisible()
await expect(apiKeyDialog.getByText(fullSecret, { exact: true })).not.toBeVisible() await expect(apiKeyDialog.getByText(fullSecret, { exact: true })).not.toBeVisible()
@@ -22,7 +22,7 @@ Then('I should see the Agent v2 Web app access URL', async function (this: DifyW
const webAppCard = getWebAppCard(this) const webAppCard = getWebAppCard(this)
await expect(webAppCard.getByRole('heading', { name: 'Web app' })).toBeVisible() await expect(webAppCard.getByRole('heading', { name: 'Web app' })).toBeVisible()
await expect(webAppCard.getByText('Access URL')).toBeVisible() await expect(webAppCard.getByText('Web App URL')).toBeVisible()
await expect(webAppCard.getByLabel('Copy access URL')).toBeEnabled() await expect(webAppCard.getByLabel('Copy access URL')).toBeEnabled()
await expect(webAppCard.getByRole('link', { name: 'Launch' })).toBeVisible() await expect(webAppCard.getByRole('link', { name: 'Launch' })).toBeVisible()
}) })
@@ -1,7 +1,7 @@
import type { DifyWorld } from '../../support/world' import type { DifyWorld } from '../../support/world'
import { When } from '@cucumber/cucumber' import { When } from '@cucumber/cucumber'
When('I navigate to the app overview page', async function (this: DifyWorld) { When('I navigate to the app access point page', async function (this: DifyWorld) {
const appId = this.createdAppIds.at(-1) const appId = this.createdAppIds.at(-1)
await this.getPage().goto(`/app/${appId}/overview`) await this.getPage().goto(`/app/${appId}/access-point`)
}) })
@@ -7,9 +7,8 @@ When('I open the publish panel', async function (this: DifyWorld) {
}) })
When('I publish the app', async function (this: DifyWorld) { When('I publish the app', async function (this: DifyWorld) {
await this.getPage() const publishPanel = this.getPage().getByRole('dialog')
.getByRole('button', { name: /Publish Update/ }) await publishPanel.getByRole('button', { name: 'Publish', exact: true }).click()
.click()
}) })
Then('the app should be marked as published', async function (this: DifyWorld) { Then('the app should be marked as published', async function (this: DifyWorld) {
@@ -23,15 +23,6 @@ Given('a new runnable workflow app has been published', async function (this: Di
this.shareURL = getAppSiteURL(appDetail) this.shareURL = getAppSiteURL(appDetail)
}) })
When('I open the app information panel', async function (this: DifyWorld) {
const appName = this.lastCreatedAppName
if (!appName) {
throw new Error('No app name available. Create an app before opening its information panel.')
}
await this.getPage().getByRole('button', { name: appName }).click()
})
const getWebAppSwitch = (world: DifyWorld) => { const getWebAppSwitch = (world: DifyWorld) => {
const webAppCard = world.getPage().getByRole('region', { name: 'Web App' }) const webAppCard = world.getPage().getByRole('region', { name: 'Web App' })
return webAppCard.getByRole('switch', { name: 'Web App' }) return webAppCard.getByRole('switch', { name: 'Web App' })
@@ -73,7 +64,7 @@ When('I enable the Web App', async function (this: DifyWorld) {
Then('the Web App should be in service', async function (this: DifyWorld) { Then('the Web App should be in service', async function (this: DifyWorld) {
const webAppCard = this.getPage().getByRole('region', { name: 'Web App' }) const webAppCard = this.getPage().getByRole('region', { name: 'Web App' })
await expect(webAppCard.getByText('In Service', { exact: true })).toBeVisible({ await expect(webAppCard.getByText(/^In service$/i)).toBeVisible({
timeout: 10_000, timeout: 10_000,
}) })
}) })
+2
View File
@@ -10,6 +10,8 @@ const config: KnipConfig = {
workspaces: { workspaces: {
web: { web: {
entry: [ entry: [
// todo: Keep the deploy drawer analyzed while the deployments routes are disabled. Delete this entry when relative files are deleted.
'features/deployments/deploy-drawer/index.tsx!',
'scripts/**/*.{js,ts,mjs}', 'scripts/**/*.{js,ts,mjs}',
'bin/**/*.{js,ts,mjs}', 'bin/**/*.{js,ts,mjs}',
'tsslint.config.ts', 'tsslint.config.ts',
-39
View File
@@ -145,16 +145,6 @@
"count": 1 "count": 1
} }
}, },
"web/app/components/app-sidebar/app-info/app-info-modals.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app-sidebar/app-info/app-operations.tsx": {
"eslint-react/set-state-in-effect": {
"count": 4
}
},
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": { "web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
"erasable-syntax-only/enums": { "erasable-syntax-only/enums": {
"count": 1 "count": 1
@@ -548,14 +538,6 @@
"count": 1 "count": 1
} }
}, },
"web/app/components/app/overview/app-card-sections.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/app/overview/workflow-hidden-input-fields.tsx": { "web/app/components/app/overview/workflow-hidden-input-fields.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
@@ -3454,22 +3436,6 @@
"count": 4 "count": 4
} }
}, },
"web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/tools/workflow-tool/configure-button.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/tools/workflow-tool/index.tsx": { "web/app/components/tools/workflow-tool/index.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
@@ -5664,11 +5630,6 @@
"count": 6 "count": 6
} }
}, },
"web/service/fetch.spec.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/fetch.ts": { "web/service/fetch.ts": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
+5
View File
@@ -1,7 +1,12 @@
import { consoleRouterContract as generatedConsoleRouterContract } from './generated/api/console/router.gen' import { consoleRouterContract as generatedConsoleRouterContract } from './generated/api/console/router.gen'
import { contract as enterpriseAppDeployContract } from './generated/enterprise-app-deploy/orpc.gen'
import { contract as knowledgeFsContract } from './generated/knowledge-fs/orpc.gen' import { contract as knowledgeFsContract } from './generated/knowledge-fs/orpc.gen'
export const consoleRouterContract = { export const consoleRouterContract = {
...generatedConsoleRouterContract, ...generatedConsoleRouterContract,
enterprise: {
...generatedConsoleRouterContract.enterprise,
appDeploy: enterpriseAppDeployContract,
},
knowledgeFs: knowledgeFsContract, knowledgeFs: knowledgeFsContract,
} }
@@ -1004,6 +1004,7 @@ export type WorkflowResponse = {
updated_at: number updated_at: number
updated_by?: SimpleAccountResponse | null updated_by?: SimpleAccountResponse | null
version: string version: string
version_number?: number | null
} }
export type SyncDraftWorkflowPayload = { export type SyncDraftWorkflowPayload = {
@@ -2029,6 +2029,7 @@ export const zWorkflowResponse = z.object({
updated_at: z.int(), updated_at: z.int(),
updated_by: zSimpleAccountResponse.nullish(), updated_by: zSimpleAccountResponse.nullish(),
version: z.string(), version: z.string(),
version_number: z.int().nullish(),
}) })
/** /**
@@ -175,6 +175,7 @@ export type WorkflowResponse = {
updated_at: number updated_at: number
updated_by?: SimpleAccountResponse | null updated_by?: SimpleAccountResponse | null
version: string version: string
version_number?: number | null
} }
export type DraftWorkflowSyncPayload = { export type DraftWorkflowSyncPayload = {
@@ -480,6 +480,7 @@ export const zWorkflowResponse = z.object({
updated_at: z.int(), updated_at: z.int(),
updated_by: zSimpleAccountResponse.nullish(), updated_by: zSimpleAccountResponse.nullish(),
version: z.string(), version: z.string(),
version_number: z.int().nullish(),
}) })
/** /**
@@ -71,6 +71,7 @@ export type SnippetWorkflowResponse = {
updated_at: number updated_at: number
updated_by?: SimpleAccountResponse | null updated_by?: SimpleAccountResponse | null
version: string version: string
version_number?: number | null
} }
export type SnippetDraftSyncPayload = { export type SnippetDraftSyncPayload = {
@@ -317,6 +317,7 @@ export const zSnippetWorkflowResponse = z.object({
updated_at: z.int(), updated_at: z.int(),
updated_by: zSimpleAccountResponse.nullish(), updated_by: zSimpleAccountResponse.nullish(),
version: z.string(), version: z.string(),
version_number: z.int().nullish(),
}) })
/** /**
@@ -0,0 +1,307 @@
// This file is auto-generated by @hey-api/openapi-ts
import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zConsoleAccessServiceCreateEnvironmentApiKeyPath,
zConsoleAccessServiceCreateEnvironmentApiKeyResponse,
zConsoleAccessServiceDeleteEnvironmentApiKeyPath,
zConsoleAccessServiceDeleteEnvironmentApiKeyResponse,
zConsoleAccessServiceGetEnvironmentApiPath,
zConsoleAccessServiceGetEnvironmentApiResponse,
zConsoleAccessServiceGetEnvironmentMcpServerPath,
zConsoleAccessServiceGetEnvironmentMcpServerResponse,
zConsoleAccessServiceGetEnvironmentSitePath,
zConsoleAccessServiceGetEnvironmentSiteResponse,
zConsoleAccessServiceGetEnvironmentWebAppSubjectsPath,
zConsoleAccessServiceGetEnvironmentWebAppSubjectsResponse,
zConsoleAccessServiceListEnvironmentApiKeysPath,
zConsoleAccessServiceListEnvironmentApiKeysResponse,
zConsoleAccessServiceListEnvironmentTriggersPath,
zConsoleAccessServiceListEnvironmentTriggersResponse,
zConsoleAccessServiceResetEnvironmentSiteAccessTokenPath,
zConsoleAccessServiceResetEnvironmentSiteAccessTokenResponse,
zConsoleAccessServiceUpdateEnvironmentApiBody,
zConsoleAccessServiceUpdateEnvironmentApiPath,
zConsoleAccessServiceUpdateEnvironmentApiResponse,
zConsoleAccessServiceUpdateEnvironmentSiteBody,
zConsoleAccessServiceUpdateEnvironmentSitePath,
zConsoleAccessServiceUpdateEnvironmentSiteResponse,
zConsoleAccessServiceUpdateEnvironmentWebAppAccessModeBody,
zConsoleAccessServiceUpdateEnvironmentWebAppAccessModePath,
zConsoleAccessServiceUpdateEnvironmentWebAppAccessModeResponse,
zConsoleDeploymentServiceDeployWorkflowBody,
zConsoleDeploymentServiceDeployWorkflowPath,
zConsoleDeploymentServiceDeployWorkflowResponse,
zConsoleDeploymentServiceGetEnvironmentDeploymentPath,
zConsoleDeploymentServiceGetEnvironmentDeploymentResponse,
zConsoleDeploymentServiceGetWorkflowDeploymentOptionsPath,
zConsoleDeploymentServiceGetWorkflowDeploymentOptionsResponse,
zConsoleDeploymentServiceListAppEnvironmentsPath,
zConsoleDeploymentServiceListAppEnvironmentsResponse,
zConsoleDeploymentServiceListEnvironmentDeploymentsPath,
zConsoleDeploymentServiceListEnvironmentDeploymentsResponse,
zConsoleDeploymentServicePrecheckWorkflowDeploymentPath,
zConsoleDeploymentServicePrecheckWorkflowDeploymentResponse,
zConsoleDeploymentServiceUndeployWorkflowPath,
zConsoleDeploymentServiceUndeployWorkflowResponse,
} from './zod.gen'
export const listAppEnvironments = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleDeploymentService_ListAppEnvironments',
path: '/enterprise/app-deploy/apps/{app_id}/environments',
tags: ['ConsoleDeploymentService'],
})
.input(z.object({ params: zConsoleDeploymentServiceListAppEnvironmentsPath }))
.output(zConsoleDeploymentServiceListAppEnvironmentsResponse)
export const listEnvironmentDeployments = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleDeploymentService_ListEnvironmentDeployments',
path: '/enterprise/app-deploy/apps/{app_id}/workflows/environment-deployments',
tags: ['ConsoleDeploymentService'],
})
.input(z.object({ params: zConsoleDeploymentServiceListEnvironmentDeploymentsPath }))
.output(zConsoleDeploymentServiceListEnvironmentDeploymentsResponse)
export const getEnvironmentDeployment = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleDeploymentService_GetEnvironmentDeployment',
path: '/enterprise/app-deploy/apps/{app_id}/workflows/environment-deployments/{environment_id}',
tags: ['ConsoleDeploymentService'],
})
.input(z.object({ params: zConsoleDeploymentServiceGetEnvironmentDeploymentPath }))
.output(zConsoleDeploymentServiceGetEnvironmentDeploymentResponse)
export const getWorkflowDeploymentOptions = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleDeploymentService_GetWorkflowDeploymentOptions',
path: '/enterprise/app-deploy/apps/{app_id}/workflows/{workflow_id}/environments/{environment_id}/deployment-options',
tags: ['ConsoleDeploymentService'],
})
.input(z.object({ params: zConsoleDeploymentServiceGetWorkflowDeploymentOptionsPath }))
.output(zConsoleDeploymentServiceGetWorkflowDeploymentOptionsResponse)
export const deployWorkflow = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'ConsoleDeploymentService_DeployWorkflow',
path: '/enterprise/app-deploy/apps/{app_id}/workflows/{workflow_id}/environments/{environment_id}/deployment:deploy',
tags: ['ConsoleDeploymentService'],
})
.input(
z.object({
body: zConsoleDeploymentServiceDeployWorkflowBody,
params: zConsoleDeploymentServiceDeployWorkflowPath,
}),
)
.output(zConsoleDeploymentServiceDeployWorkflowResponse)
export const undeployWorkflow = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'ConsoleDeploymentService_UndeployWorkflow',
path: '/enterprise/app-deploy/apps/{app_id}/workflows/{workflow_id}/environments/{environment_id}/deployment:undeploy',
tags: ['ConsoleDeploymentService'],
})
.input(z.object({ params: zConsoleDeploymentServiceUndeployWorkflowPath }))
.output(zConsoleDeploymentServiceUndeployWorkflowResponse)
export const precheckWorkflowDeployment = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleDeploymentService_PrecheckWorkflowDeployment',
path: '/enterprise/app-deploy/apps/{app_id}/workflows/{workflow_id}:precheck',
tags: ['ConsoleDeploymentService'],
})
.input(z.object({ params: zConsoleDeploymentServicePrecheckWorkflowDeploymentPath }))
.output(zConsoleDeploymentServicePrecheckWorkflowDeploymentResponse)
export const deploymentService = {
listAppEnvironments,
listEnvironmentDeployments,
getEnvironmentDeployment,
getWorkflowDeploymentOptions,
deployWorkflow,
undeployWorkflow,
precheckWorkflowDeployment,
}
export const getEnvironmentApi = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleAccessService_GetEnvironmentAPI',
path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api',
tags: ['ConsoleAccessService'],
})
.input(z.object({ params: zConsoleAccessServiceGetEnvironmentApiPath }))
.output(zConsoleAccessServiceGetEnvironmentApiResponse)
export const updateEnvironmentApi = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'ConsoleAccessService_UpdateEnvironmentAPI',
path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api',
tags: ['ConsoleAccessService'],
})
.input(
z.object({
body: zConsoleAccessServiceUpdateEnvironmentApiBody,
params: zConsoleAccessServiceUpdateEnvironmentApiPath,
}),
)
.output(zConsoleAccessServiceUpdateEnvironmentApiResponse)
export const listEnvironmentApiKeys = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleAccessService_ListEnvironmentApiKeys',
path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api-keys',
tags: ['ConsoleAccessService'],
})
.input(z.object({ params: zConsoleAccessServiceListEnvironmentApiKeysPath }))
.output(zConsoleAccessServiceListEnvironmentApiKeysResponse)
export const createEnvironmentApiKey = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'ConsoleAccessService_CreateEnvironmentApiKey',
path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api-keys',
tags: ['ConsoleAccessService'],
})
.input(z.object({ params: zConsoleAccessServiceCreateEnvironmentApiKeyPath }))
.output(zConsoleAccessServiceCreateEnvironmentApiKeyResponse)
export const deleteEnvironmentApiKey = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'ConsoleAccessService_DeleteEnvironmentApiKey',
path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api-keys/{api_key_id}',
tags: ['ConsoleAccessService'],
})
.input(z.object({ params: zConsoleAccessServiceDeleteEnvironmentApiKeyPath }))
.output(zConsoleAccessServiceDeleteEnvironmentApiKeyResponse)
export const getEnvironmentMcpServer = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleAccessService_GetEnvironmentMCPServer',
path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/server',
tags: ['ConsoleAccessService'],
})
.input(z.object({ params: zConsoleAccessServiceGetEnvironmentMcpServerPath }))
.output(zConsoleAccessServiceGetEnvironmentMcpServerResponse)
export const getEnvironmentSite = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleAccessService_GetEnvironmentSite',
path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/site',
tags: ['ConsoleAccessService'],
})
.input(z.object({ params: zConsoleAccessServiceGetEnvironmentSitePath }))
.output(zConsoleAccessServiceGetEnvironmentSiteResponse)
export const updateEnvironmentSite = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'ConsoleAccessService_UpdateEnvironmentSite',
path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/site',
tags: ['ConsoleAccessService'],
})
.input(
z.object({
body: zConsoleAccessServiceUpdateEnvironmentSiteBody,
params: zConsoleAccessServiceUpdateEnvironmentSitePath,
}),
)
.output(zConsoleAccessServiceUpdateEnvironmentSiteResponse)
export const resetEnvironmentSiteAccessToken = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'ConsoleAccessService_ResetEnvironmentSiteAccessToken',
path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/site/access-token-reset',
tags: ['ConsoleAccessService'],
})
.input(z.object({ params: zConsoleAccessServiceResetEnvironmentSiteAccessTokenPath }))
.output(zConsoleAccessServiceResetEnvironmentSiteAccessTokenResponse)
export const listEnvironmentTriggers = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleAccessService_ListEnvironmentTriggers',
path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/triggers',
tags: ['ConsoleAccessService'],
})
.input(z.object({ params: zConsoleAccessServiceListEnvironmentTriggersPath }))
.output(zConsoleAccessServiceListEnvironmentTriggersResponse)
export const updateEnvironmentWebAppAccessMode = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'ConsoleAccessService_UpdateEnvironmentWebAppAccessMode',
path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/webapp/access-mode',
tags: ['ConsoleAccessService'],
})
.input(
z.object({
body: zConsoleAccessServiceUpdateEnvironmentWebAppAccessModeBody,
params: zConsoleAccessServiceUpdateEnvironmentWebAppAccessModePath,
}),
)
.output(zConsoleAccessServiceUpdateEnvironmentWebAppAccessModeResponse)
export const getEnvironmentWebAppSubjects = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ConsoleAccessService_GetEnvironmentWebAppSubjects',
path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/webapp/subjects',
tags: ['ConsoleAccessService'],
})
.input(z.object({ params: zConsoleAccessServiceGetEnvironmentWebAppSubjectsPath }))
.output(zConsoleAccessServiceGetEnvironmentWebAppSubjectsResponse)
export const accessService = {
getEnvironmentApi,
updateEnvironmentApi,
listEnvironmentApiKeys,
createEnvironmentApiKey,
deleteEnvironmentApiKey,
getEnvironmentMcpServer,
getEnvironmentSite,
updateEnvironmentSite,
resetEnvironmentSiteAccessToken,
listEnvironmentTriggers,
updateEnvironmentWebAppAccessMode,
getEnvironmentWebAppSubjects,
}
export const contract = {
deploymentService,
accessService,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,338 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from '@hey-api/openapi-ts'
import { loadOpenApiYaml } from './openapi-yaml'
type JsonObject = Record<string, unknown>
type OpenApiDocument = JsonObject & {
components?: OpenApiComponents
paths?: Record<string, unknown>
}
type OpenApiComponents = JsonObject & {
schemas?: Record<string, OpenApiSchema>
}
type OpenApiMediaType = JsonObject & {
schema?: unknown
}
type OpenApiOperation = JsonObject & {
operationId?: string
responses?: Record<string, OpenApiResponse>
}
type OpenApiPathItem = Record<string, unknown>
type OpenApiResponse = JsonObject & {
content?: Record<string, OpenApiMediaType>
}
type OpenApiSchema = JsonObject & {
enum?: unknown[]
format?: string
properties?: Record<string, OpenApiSchema>
type?: string | string[]
}
type ContractOperation = {
id: string
operationId?: string
tags?: readonly string[]
}
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER
? path.resolve(process.env.DIFY_ENTERPRISE_SERVER)
: path.resolve(currentDir, '../../../dify-enterprise/server')
const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/appdeploy/openapi.yaml')
const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put'])
const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/')
const isObject = (value: unknown): value is JsonObject => {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
const isOpenApiSchema = (value: unknown): value is OpenApiSchema => {
return isObject(value)
}
const asOpenApiOperation = (value: unknown): OpenApiOperation | undefined => {
return isObject(value) ? (value as OpenApiOperation) : undefined
}
const asOpenApiResponse = (value: unknown): OpenApiResponse | undefined => {
return isObject(value) ? (value as OpenApiResponse) : undefined
}
const asOpenApiMediaType = (value: unknown): OpenApiMediaType | undefined => {
return isObject(value) ? (value as OpenApiMediaType) : undefined
}
const stripConsoleApiPrefix = (routePath: string) => {
if (isConsoleApiPath(routePath)) return routePath.replace('/console/api', '')
return routePath
}
const stripSchemaNamePrefix = (schemaName: string) => {
return schemaName
.replace(/^dify\.enterprise\.api\.enterprise\./, '')
.replace(/^dify\.enterprise\.api\.appdeploy\.v1\./, '')
.replace(/^dify\.enterprise\.api\.appdeploy\./, '')
.replace(/^pagination\./, '')
}
const contractTagSegment = (tag?: string) => {
if (tag === 'ConsoleDeploymentService') return 'DeploymentService'
if (tag === 'ConsoleAccessService') return 'AccessService'
return tag || 'default'
}
const contractNameSegments = (operation: ContractOperation) => {
const operationId = operation.operationId || operation.id
const tag = operation.tags?.[0]
const tagPrefixPattern = tag ? new RegExp(`^${tag}[._/-]`) : undefined
const name = tagPrefixPattern ? operationId.replace(tagPrefixPattern, '') : operationId
const segments = name.split(/[._/-]+/).filter(Boolean)
return segments.length > 0 ? segments : [operationId]
}
const contractPathSegments = (operation: ContractOperation) => {
return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)]
}
const hasSchemaLessResponseContent = (operation: OpenApiOperation) => {
if (!isObject(operation.responses)) return false
return Object.values(operation.responses).some((response) => {
const openApiResponse = asOpenApiResponse(response)
if (!openApiResponse || !isObject(openApiResponse.content)) return false
return Object.values(openApiResponse.content).some((mediaType) => {
const openApiMediaType = asOpenApiMediaType(mediaType)
return !!openApiMediaType && !('schema' in openApiMediaType)
})
})
}
// protoc-gen-openapi emits google.api.HttpBody responses as `*/*: {}`. Skip these
// raw download operations until the source OpenAPI exposes an explicit schema.
const stripSchemaLessResponseOperations = (pathItem: OpenApiPathItem) => {
return Object.fromEntries(
Object.entries(pathItem).filter(([method, operation]) => {
if (!operationMethods.has(method.toLowerCase())) return true
const openApiOperation = asOpenApiOperation(operation)
return !openApiOperation || !hasSchemaLessResponseContent(openApiOperation)
}),
)
}
const toWords = (value: string) => {
return value
.replace(/[{}]/g, '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[^a-z0-9]+/i)
.filter(Boolean)
}
const toPascalCase = (words: string[]) => {
return words.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join('')
}
const commonWordPrefix = (values: string[]) => {
const wordLists = values.map((value) => value.split('_'))
const firstWords = wordLists[0] ?? []
const prefix: string[] = []
for (const [index, word] of firstWords.entries()) {
if (!wordLists.every((words) => words[index] === word)) break
prefix.push(word)
}
return prefix
}
const enumSchemaNameFromValues = (values: unknown[]) => {
if (values.length === 0 || !values.every((value) => typeof value === 'string')) return undefined
const prefix = commonWordPrefix(values)
if (prefix.length < 2) return undefined
return toPascalCase(prefix.map((word) => word.toLowerCase()))
}
const findSchemaEntry = (
schemas: Record<string, OpenApiSchema>,
schemaName: string,
): [string, OpenApiSchema] | undefined => {
return Object.entries(schemas).find(([name]) => stripSchemaNamePrefix(name) === schemaName)
}
const enumValuesKey = (values: unknown[]) => JSON.stringify(values)
const reusableEnumSchema = (propertySchema: OpenApiSchema): OpenApiSchema => ({
...(propertySchema.format ? { format: propertySchema.format } : {}),
enum: propertySchema.enum,
type: propertySchema.type ?? 'string',
})
const enumSchemaKey = (
schemas: Record<string, OpenApiSchema>,
preferredName: string,
valuesKey: string,
valuesToSchemaKey: Map<string, string>,
schemaName: string,
propertyName: string,
) => {
const existingKey = valuesToSchemaKey.get(valuesKey)
if (existingKey) return existingKey
const existingEnumEntry = findSchemaEntry(schemas, preferredName)
if (!existingEnumEntry) return preferredName
const existingEnumValues = existingEnumEntry[1].enum
if (Array.isArray(existingEnumValues) && enumValuesKey(existingEnumValues) === valuesKey)
return existingEnumEntry[0]
return `${stripSchemaNamePrefix(schemaName)}${toPascalCase(toWords(propertyName))}`
}
const promoteInlineEnumSchema = (
schemas: Record<string, OpenApiSchema>,
schemaName: string,
properties: Record<string, OpenApiSchema>,
propertyName: string,
propertySchema: OpenApiSchema,
valuesToSchemaKey: Map<string, string>,
) => {
if (!Array.isArray(propertySchema.enum)) return
const preferredName = enumSchemaNameFromValues(propertySchema.enum)
if (!preferredName) return
const valuesKey = enumValuesKey(propertySchema.enum)
const key = enumSchemaKey(
schemas,
preferredName,
valuesKey,
valuesToSchemaKey,
schemaName,
propertyName,
)
if (!schemas[key]) schemas[key] = reusableEnumSchema(propertySchema)
valuesToSchemaKey.set(valuesKey, key)
properties[propertyName] = {
$ref: `#/components/schemas/${key}`,
}
}
// gnostic's protoc-gen-openapi inlines proto enum schemas into every field.
// Promote prefixable inline enums to reusable schemas so Hey API can emit
// runtime enum objects from the generated contract.
const promoteReusableEnumSchemasForHeyApi = (document: OpenApiDocument) => {
const schemas = document.components?.schemas
if (!schemas) return
const valuesToSchemaKey = new Map<string, string>()
Object.entries(schemas).forEach(([schemaName, schema]) => {
const properties = schema.properties
if (!properties) return
Object.entries(properties).forEach(([propertyName, propertySchema]) => {
if (!isOpenApiSchema(propertySchema)) return
promoteInlineEnumSchema(
schemas,
schemaName,
properties,
propertyName,
propertySchema,
valuesToSchemaKey,
)
})
})
}
const normalizeEnterpriseOpenApi = () => {
const openApi = loadOpenApiYaml(fs.readFileSync(enterpriseOpenApiPath, 'utf8'))
if (!openApi || typeof openApi !== 'object' || Array.isArray(openApi))
throw new Error(`Invalid enterprise OpenAPI document: ${enterpriseOpenApiPath}`)
const document = openApi as OpenApiDocument
const paths = document.paths ?? {}
document.paths = Object.fromEntries(
Object.entries(paths)
.filter(([routePath]) => isConsoleApiPath(routePath))
.map(([routePath, pathItem]) => {
if (!isObject(pathItem)) return [stripConsoleApiPrefix(routePath), pathItem]
return [stripConsoleApiPrefix(routePath), stripSchemaLessResponseOperations(pathItem)]
})
.filter(([, pathItem]) => !isObject(pathItem) || Object.keys(pathItem).length > 0),
)
promoteReusableEnumSchemasForHeyApi(document)
return document
}
export default defineConfig({
input: normalizeEnterpriseOpenApi(),
output: {
entryFile: false,
path: 'generated/enterprise-app-deploy',
fileName: {
suffix: '.gen',
},
postProcess: [
{
command: 'vp',
args: ['fmt', '{{path}}'],
},
],
},
parser: {
transforms: {
schemaName: stripSchemaNamePrefix,
},
},
plugins: [
{
name: '@hey-api/typescript',
comments: false,
enums: {
mode: 'javascript',
},
},
'zod',
{
name: 'orpc',
contracts: {
strategy: 'single',
contractName: {
name: '{{name}}',
casing: 'camelCase',
},
nesting: contractPathSegments,
segmentName: {
name: '{{name}}',
casing: 'camelCase',
},
},
validator: 'zod',
},
],
})
+5
View File
@@ -20,6 +20,10 @@
"types": "./generated/enterprise/*.ts", "types": "./generated/enterprise/*.ts",
"import": "./generated/enterprise/*.ts" "import": "./generated/enterprise/*.ts"
}, },
"./enterprise-app-deploy/*": {
"types": "./generated/enterprise-app-deploy/*.ts",
"import": "./generated/enterprise-app-deploy/*.ts"
},
"./knowledge-fs/*": { "./knowledge-fs/*": {
"types": "./generated/knowledge-fs/*.ts", "types": "./generated/knowledge-fs/*.ts",
"import": "./generated/knowledge-fs/*.ts" "import": "./generated/knowledge-fs/*.ts"
@@ -28,6 +32,7 @@
"scripts": { "scripts": {
"gen-api-contract": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api", "gen-api-contract": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api",
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts", "gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts",
"gen-enterprise-app-deploy-contract": "openapi-ts -f openapi-ts.enterprise-app-deploy.config.ts",
"gen-knowledge-fs-contract": "node scripts/generate-knowledge-fs-contract.mjs", "gen-knowledge-fs-contract": "node scripts/generate-knowledge-fs-contract.mjs",
"test": "vp test", "test": "vp test",
"type-check": "tsc" "type-check": "tsc"
@@ -0,0 +1,3 @@
<svg width="8.27613" height="5.08087" viewBox="0 0 8.27613 5.08087" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.13806 0L0 4.13807L0.942807 5.08087L4.13806 1.8856L7.33333 5.08087L8.27613 4.13807L4.13806 0Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 253 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.27624 3.99996L3.80484 2.47137L2.86203 1.52856L0.390625 3.99996L2.86203 6.47137L3.80484 5.52856L2.27624 3.99996ZM6.39063 3.99996L4.86203 2.47137L5.80484 1.52856L8.27627 3.99996L5.80484 6.47137L4.86203 5.52856L6.39063 3.99996ZM9.33347 1.99996H14.0001C14.3683 1.99996 14.6668 2.29844 14.6668 2.66663V13.3333C14.6668 13.7015 14.3683 14 14.0001 14H2.0001C1.63191 14 1.33343 13.7015 1.33343 13.3333V8H2.66677V12.6667H13.3335V3.3333H9.33347V1.99996Z" fill="#354052"/>
</svg>

After

Width:  |  Height:  |  Size: 576 B

@@ -0,0 +1,3 @@
<svg width="12" height="39" viewBox="0 0 12 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.9062 0.130859L0.482939 38.4953" stroke="#101828" stroke-opacity="0.04"/>
</svg>

After

Width:  |  Height:  |  Size: 189 B

@@ -0,0 +1,4 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.91699 9.33317C3.88349 9.33317 4.66699 10.1166 4.66699 11.0832C4.66699 12.0497 3.88349 12.8332 2.91699 12.8332H1.75033C1.42816 12.8332 1.16699 12.572 1.16699 12.2498V11.0832C1.16699 10.1166 1.9505 9.33317 2.91699 9.33317ZM2.91699 10.4998C2.59482 10.4998 2.33366 10.761 2.33366 11.0832V11.6665H2.91699C3.23916 11.6665 3.50033 11.4053 3.50033 11.0832C3.50033 10.761 3.23916 10.4998 2.91699 10.4998Z" fill="#354052"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2503 1.1665C12.5725 1.16651 12.8337 1.42767 12.8337 1.74984C12.8337 4.22292 11.6271 6.07192 9.91699 7.68913V10.4998C9.91699 10.7208 9.79219 10.9228 9.59456 11.0216L6.6779 12.48C6.49712 12.5704 6.28247 12.5606 6.11051 12.4543C5.93854 12.3481 5.83366 12.1603 5.83366 11.9582V10.158L3.84212 8.1665H2.04199C1.83982 8.1665 1.6521 8.06162 1.54582 7.88965C1.4396 7.71769 1.42979 7.50305 1.52018 7.32227L2.97852 4.4056C3.07733 4.20798 3.27938 4.08317 3.50033 4.08317H6.31104C7.92825 2.37309 9.77724 1.1665 12.2503 1.1665ZM7.00033 10.186V11.0142L8.75033 10.1392V8.698L7.00033 10.186ZM11.6362 2.36336C9.82895 2.54598 8.38869 3.53818 6.99463 5.05843L4.87606 7.5507L6.44889 9.12354L8.94116 7.00496C10.4616 5.61077 11.4537 4.17089 11.6362 2.36336ZM2.98592 6.99984H3.81421L5.3016 5.24984H3.86092L2.98592 6.99984Z" fill="#354052"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,6 +1,6 @@
{ {
"prefix": "custom-public", "prefix": "custom-public",
"lastModified": 1784870906, "lastModified": 1785332090,
"icons": { "icons": {
"agent-building-blocks": { "agent-building-blocks": {
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8.303 1.546c.178-.045.364-.051.544-.017c.23.043.432.167.573.246l3.757 2.113c.12.067.29.156.433.289l.06.06c.12.131.21.288.267.457c.07.215.063.445.063.6V9.56c0 .146.007.36-.056.563q-.055.181-.162.338l-.075.1c-.137.163-.32.274-.442.353l-5.013 3.259c-.135.088-.33.224-.556.282a1.3 1.3 0 0 1-.543.017c-.23-.043-.433-.166-.573-.245l-3.757-2.114c-.136-.077-.34-.182-.493-.35a1.3 1.3 0 0 1-.267-.456C1.993 11.09 2 10.86 2 10.704V6.441c0-.146-.007-.36.055-.563l.043-.118a1.3 1.3 0 0 1 .195-.32l.053-.059c.128-.131.282-.225.389-.294L7.86 1.755c.122-.078.273-.165.443-.209m-4.97 9.158l.001.164l.033.02l.11.062l3.264 1.836v-1.137L3.333 9.732zm4.741.917v1.076l4.464-2.901l.098-.065l.029-.02v-.034l.001-.118v-.923zm-4.74-3.419L6.74 10.12V8.982L3.333 7.066zm4.74.752v1.076l4.592-2.985V5.969zm.51-6.08l-4.631 3.01l3.429 1.93l4.664-3.032l-3.28-1.846l-.15-.082z\" clip-rule=\"evenodd\"/>" "body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8.303 1.546c.178-.045.364-.051.544-.017c.23.043.432.167.573.246l3.757 2.113c.12.067.29.156.433.289l.06.06c.12.131.21.288.267.457c.07.215.063.445.063.6V9.56c0 .146.007.36-.056.563q-.055.181-.162.338l-.075.1c-.137.163-.32.274-.442.353l-5.013 3.259c-.135.088-.33.224-.556.282a1.3 1.3 0 0 1-.543.017c-.23-.043-.433-.166-.573-.245l-3.757-2.114c-.136-.077-.34-.182-.493-.35a1.3 1.3 0 0 1-.267-.456C1.993 11.09 2 10.86 2 10.704V6.441c0-.146-.007-.36.055-.563l.043-.118a1.3 1.3 0 0 1 .195-.32l.053-.059c.128-.131.282-.225.389-.294L7.86 1.755c.122-.078.273-.165.443-.209m-4.97 9.158l.001.164l.033.02l.11.062l3.264 1.836v-1.137L3.333 9.732zm4.741.917v1.076l4.464-2.901l.098-.065l.029-.02v-.034l.001-.118v-.923zm-4.74-3.419L6.74 10.12V8.982L3.333 7.066zm4.74.752v1.076l4.592-2.985V5.969zm.51-6.08l-4.631 3.01l3.429 1.93l4.664-3.032l-3.28-1.846l-.15-.082z\" clip-rule=\"evenodd\"/>"
+7 -7
View File
@@ -1,4 +1,4 @@
export type IconifyJSON = { export interface IconifyJSON {
prefix: string prefix: string
icons: Record<string, IconifyIcon> icons: Record<string, IconifyIcon>
aliases?: Record<string, IconifyAlias> aliases?: Record<string, IconifyAlias>
@@ -7,7 +7,7 @@ export type IconifyJSON = {
lastModified?: number lastModified?: number
} }
export type IconifyIcon = { export interface IconifyIcon {
body: string body: string
left?: number left?: number
top?: number top?: number
@@ -18,11 +18,11 @@ export type IconifyIcon = {
vFlip?: boolean vFlip?: boolean
} }
export type IconifyAlias = { export interface IconifyAlias extends Omit<IconifyIcon, 'body'> {
parent: string parent: string
} & Omit<IconifyIcon, 'body'> }
export type IconifyInfo = { export interface IconifyInfo {
prefix: string prefix: string
name: string name: string
total: number total: number
@@ -40,11 +40,11 @@ export type IconifyInfo = {
palette?: boolean palette?: boolean
} }
export type IconifyMetaData = { export interface IconifyMetaData {
[key: string]: unknown [key: string]: unknown
} }
export type IconifyChars = { export interface IconifyChars {
[key: string]: string [key: string]: string
} }
@@ -1,8 +1,8 @@
'use strict' 'use strict'
const chars = require('./chars.json')
const icons = require('./icons.json') const icons = require('./icons.json')
const info = require('./info.json') const info = require('./info.json')
const metadata = require('./metadata.json') const metadata = require('./metadata.json')
const chars = require('./chars.json')
module.exports = { icons, info, metadata, chars } module.exports = { icons, info, metadata, chars }
@@ -1,6 +1,6 @@
{ {
"prefix": "custom-vender", "prefix": "custom-vender",
"lastModified": 1782516559, "lastModified": 1786000677,
"icons": { "icons": {
"agent-v2-access-point": { "agent-v2-access-point": {
"body": "<g fill=\"none\"><path d=\"M7.5 11.25C7.91421 11.25 8.25 11.5858 8.25 12V14.25C8.25 14.6642 7.91421 15 7.5 15C7.08579 15 6.75 14.6642 6.75 14.25V12C6.75 11.5858 7.08579 11.25 7.5 11.25Z\" fill=\"currentColor\"/><path d=\"M2.19653 2.19653C2.48937 1.90372 2.96418 1.90382 3.25708 2.19653L8.03027 6.96973C8.09162 7.03108 8.13966 7.10082 8.17529 7.1748C8.19164 7.20869 8.20587 7.24378 8.21704 7.28027C8.24638 7.37633 8.25641 7.477 8.24634 7.57617C8.23743 7.66451 8.21216 7.74788 8.17529 7.82446C8.13963 7.89868 8.09176 7.96874 8.03027 8.03027L3.25708 12.8035C2.96419 13.096 2.48932 13.0962 2.19653 12.8035C1.90394 12.5107 1.90405 12.0358 2.19653 11.7429L5.68945 8.25H0.75C0.335786 8.25 0 7.91421 0 7.5C0 7.08579 0.335786 6.75 0.75 6.75H5.68945L2.19653 3.25708C1.90389 2.96423 1.90388 2.48937 2.19653 2.19653Z\" fill=\"currentColor\"/><path d=\"M10.1521 10.1521C10.445 9.85921 10.9198 9.85921 11.2126 10.1521L12.8035 11.7429C13.096 12.0358 13.0962 12.5107 12.8035 12.8035C12.5107 13.0962 12.0358 13.096 11.7429 12.8035L10.1521 11.2126C9.85921 10.9198 9.85922 10.445 10.1521 10.1521Z\" fill=\"currentColor\"/><path d=\"M14.25 6.75C14.6642 6.75 15 7.08579 15 7.5C15 7.91421 14.6642 8.25 14.25 8.25H12C11.5858 8.25 11.25 7.91421 11.25 7.5C11.25 7.08579 11.5858 6.75 12 6.75H14.25Z\" fill=\"currentColor\"/><path d=\"M11.7422 2.19653C12.035 1.90387 12.5098 1.90406 12.8027 2.19653C13.0956 2.4894 13.0955 2.96419 12.8027 3.25708L11.2119 4.8479C10.919 5.14079 10.4443 5.1408 10.1514 4.8479C9.85883 4.55497 9.85858 4.08013 10.1514 3.78735L11.7422 2.19653Z\" fill=\"currentColor\"/><path d=\"M7.5 0C7.91421 0 8.25 0.335786 8.25 0.75V3C8.25 3.41421 7.91421 3.75 7.5 3.75C7.08579 3.75 6.75 3.41421 6.75 3V0.75C6.75 0.335786 7.08579 0 7.5 0Z\" fill=\"currentColor\"/></g>", "body": "<g fill=\"none\"><path d=\"M7.5 11.25C7.91421 11.25 8.25 11.5858 8.25 12V14.25C8.25 14.6642 7.91421 15 7.5 15C7.08579 15 6.75 14.6642 6.75 14.25V12C6.75 11.5858 7.08579 11.25 7.5 11.25Z\" fill=\"currentColor\"/><path d=\"M2.19653 2.19653C2.48937 1.90372 2.96418 1.90382 3.25708 2.19653L8.03027 6.96973C8.09162 7.03108 8.13966 7.10082 8.17529 7.1748C8.19164 7.20869 8.20587 7.24378 8.21704 7.28027C8.24638 7.37633 8.25641 7.477 8.24634 7.57617C8.23743 7.66451 8.21216 7.74788 8.17529 7.82446C8.13963 7.89868 8.09176 7.96874 8.03027 8.03027L3.25708 12.8035C2.96419 13.096 2.48932 13.0962 2.19653 12.8035C1.90394 12.5107 1.90405 12.0358 2.19653 11.7429L5.68945 8.25H0.75C0.335786 8.25 0 7.91421 0 7.5C0 7.08579 0.335786 6.75 0.75 6.75H5.68945L2.19653 3.25708C1.90389 2.96423 1.90388 2.48937 2.19653 2.19653Z\" fill=\"currentColor\"/><path d=\"M10.1521 10.1521C10.445 9.85921 10.9198 9.85921 11.2126 10.1521L12.8035 11.7429C13.096 12.0358 13.0962 12.5107 12.8035 12.8035C12.5107 13.0962 12.0358 13.096 11.7429 12.8035L10.1521 11.2126C9.85921 10.9198 9.85922 10.445 10.1521 10.1521Z\" fill=\"currentColor\"/><path d=\"M14.25 6.75C14.6642 6.75 15 7.08579 15 7.5C15 7.91421 14.6642 8.25 14.25 8.25H12C11.5858 8.25 11.25 7.91421 11.25 7.5C11.25 7.08579 11.5858 6.75 12 6.75H14.25Z\" fill=\"currentColor\"/><path d=\"M11.7422 2.19653C12.035 1.90387 12.5098 1.90406 12.8027 2.19653C13.0956 2.4894 13.0955 2.96419 12.8027 3.25708L11.2119 4.8479C10.919 5.14079 10.4443 5.1408 10.1514 4.8479C9.85883 4.55497 9.85858 4.08013 10.1514 3.78735L11.7422 2.19653Z\" fill=\"currentColor\"/><path d=\"M7.5 0C7.91421 0 8.25 0.335786 8.25 0.75V3C8.25 3.41421 7.91421 3.75 7.5 3.75C7.08579 3.75 6.75 3.41421 6.75 3V0.75C6.75 0.335786 7.08579 0 7.5 0Z\" fill=\"currentColor\"/></g>",
@@ -42,6 +42,24 @@
"body": "<g fill=\"none\"><path d=\"M6.25 6.875C6.82523 6.875 7.29167 7.34128 7.29167 7.91667V9.16667C7.29167 9.74205 6.82523 10.2083 6.25 10.2083C5.67477 10.2083 5.20833 9.74205 5.20833 9.16667V7.91667C5.20833 7.34128 5.67477 6.875 6.25 6.875Z\" fill=\"currentColor\"/><path d=\"M10.4167 6.875C10.992 6.875 11.4583 7.34135 11.4583 7.91667V9.16667C11.4583 9.74199 10.992 10.2083 10.4167 10.2083C9.84135 10.2083 9.375 9.74199 9.375 9.16667V7.91667C9.375 7.34135 9.84135 6.875 10.4167 6.875Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.33333 0C9.13875 0 9.79167 0.652918 9.79167 1.45833C9.79167 2.02329 9.46964 2.51173 8.99984 2.75391V3.33822C9.38912 3.34279 9.77995 3.35006 10.175 3.36263C11.6983 3.41112 12.7377 3.42425 13.6401 3.90951C14.375 4.30477 15.0255 4.97655 15.3971 5.72347C15.5468 6.02442 15.6427 6.33532 15.7056 6.66667H15.8333C16.2936 6.66667 16.6667 7.03976 16.6667 7.5V10C16.6667 10.4602 16.2936 10.8333 15.8333 10.8333H15.8285C15.8235 11.2254 15.813 11.5735 15.7869 11.8831C15.7386 12.4571 15.6361 12.9628 15.3971 13.4432C15.0254 14.1901 14.3749 14.8619 13.6401 15.2572C12.7377 15.7424 11.6982 15.7556 10.175 15.804C8.93336 15.8436 7.73328 15.8436 6.4917 15.804C4.96843 15.7556 3.92896 15.7424 3.02653 15.2572C2.29178 14.8619 1.64121 14.1902 1.26953 13.4432C1.03058 12.9628 0.928072 12.4571 0.87972 11.8831C0.853642 11.5735 0.843216 11.2254 0.838216 10.8333H0.833333C0.373096 10.8333 0 10.4602 0 10V7.5C0 7.03976 0.373096 6.66667 0.833333 6.66667H0.9611C1.02392 6.33532 1.11984 6.02442 1.26953 5.72347C1.64119 4.97649 2.29177 4.30475 3.02653 3.90951C3.92895 3.42425 4.96837 3.41112 6.4917 3.36263C6.88671 3.35006 7.27754 3.34279 7.66683 3.33822V2.75391C7.19703 2.51173 6.875 2.02329 6.875 1.45833C6.875 0.652918 7.52792 0 8.33333 0ZM10.1213 5.02848C8.91522 4.9901 7.75142 4.9901 6.54541 5.02848C4.85908 5.08217 4.29323 5.12091 3.81592 5.3776C3.38476 5.60954 2.98015 6.02734 2.76204 6.46566C2.65217 6.68652 2.57959 6.96168 2.54069 7.4235C2.50069 7.89854 2.5 8.50363 2.5 9.37825V9.78841C2.5 10.663 2.50069 11.2681 2.54069 11.7432C2.57959 12.205 2.65215 12.4801 2.76204 12.701C2.98015 13.1393 3.38475 13.5571 3.81592 13.7891C4.29321 14.0458 4.85904 14.0845 6.54541 14.1382C7.75141 14.1766 8.91523 14.1766 10.1213 14.1382C11.8075 14.0845 12.3734 14.0458 12.8507 13.7891C13.2819 13.5572 13.6865 13.1394 13.9046 12.701C14.0145 12.4801 14.0871 12.205 14.126 11.7432C14.166 11.2681 14.1667 10.663 14.1667 9.78841V9.37825C14.1667 8.50363 14.166 7.89854 14.126 7.4235C14.0871 6.96168 14.0145 6.68652 13.9046 6.46566C13.6865 6.02729 13.2819 5.60951 12.8507 5.3776C12.3734 5.12091 11.8075 5.08217 10.1213 5.02848Z\" fill=\"currentColor\"/></g>", "body": "<g fill=\"none\"><path d=\"M6.25 6.875C6.82523 6.875 7.29167 7.34128 7.29167 7.91667V9.16667C7.29167 9.74205 6.82523 10.2083 6.25 10.2083C5.67477 10.2083 5.20833 9.74205 5.20833 9.16667V7.91667C5.20833 7.34128 5.67477 6.875 6.25 6.875Z\" fill=\"currentColor\"/><path d=\"M10.4167 6.875C10.992 6.875 11.4583 7.34135 11.4583 7.91667V9.16667C11.4583 9.74199 10.992 10.2083 10.4167 10.2083C9.84135 10.2083 9.375 9.74199 9.375 9.16667V7.91667C9.375 7.34135 9.84135 6.875 10.4167 6.875Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.33333 0C9.13875 0 9.79167 0.652918 9.79167 1.45833C9.79167 2.02329 9.46964 2.51173 8.99984 2.75391V3.33822C9.38912 3.34279 9.77995 3.35006 10.175 3.36263C11.6983 3.41112 12.7377 3.42425 13.6401 3.90951C14.375 4.30477 15.0255 4.97655 15.3971 5.72347C15.5468 6.02442 15.6427 6.33532 15.7056 6.66667H15.8333C16.2936 6.66667 16.6667 7.03976 16.6667 7.5V10C16.6667 10.4602 16.2936 10.8333 15.8333 10.8333H15.8285C15.8235 11.2254 15.813 11.5735 15.7869 11.8831C15.7386 12.4571 15.6361 12.9628 15.3971 13.4432C15.0254 14.1901 14.3749 14.8619 13.6401 15.2572C12.7377 15.7424 11.6982 15.7556 10.175 15.804C8.93336 15.8436 7.73328 15.8436 6.4917 15.804C4.96843 15.7556 3.92896 15.7424 3.02653 15.2572C2.29178 14.8619 1.64121 14.1902 1.26953 13.4432C1.03058 12.9628 0.928072 12.4571 0.87972 11.8831C0.853642 11.5735 0.843216 11.2254 0.838216 10.8333H0.833333C0.373096 10.8333 0 10.4602 0 10V7.5C0 7.03976 0.373096 6.66667 0.833333 6.66667H0.9611C1.02392 6.33532 1.11984 6.02442 1.26953 5.72347C1.64119 4.97649 2.29177 4.30475 3.02653 3.90951C3.92895 3.42425 4.96837 3.41112 6.4917 3.36263C6.88671 3.35006 7.27754 3.34279 7.66683 3.33822V2.75391C7.19703 2.51173 6.875 2.02329 6.875 1.45833C6.875 0.652918 7.52792 0 8.33333 0ZM10.1213 5.02848C8.91522 4.9901 7.75142 4.9901 6.54541 5.02848C4.85908 5.08217 4.29323 5.12091 3.81592 5.3776C3.38476 5.60954 2.98015 6.02734 2.76204 6.46566C2.65217 6.68652 2.57959 6.96168 2.54069 7.4235C2.50069 7.89854 2.5 8.50363 2.5 9.37825V9.78841C2.5 10.663 2.50069 11.2681 2.54069 11.7432C2.57959 12.205 2.65215 12.4801 2.76204 12.701C2.98015 13.1393 3.38475 13.5571 3.81592 13.7891C4.29321 14.0458 4.85904 14.0845 6.54541 14.1382C7.75141 14.1766 8.91523 14.1766 10.1213 14.1382C11.8075 14.0845 12.3734 14.0458 12.8507 13.7891C13.2819 13.5572 13.6865 13.1394 13.9046 12.701C14.0145 12.4801 14.0871 12.205 14.126 11.7432C14.166 11.2681 14.1667 10.663 14.1667 9.78841V9.37825C14.1667 8.50363 14.166 7.89854 14.126 7.4235C14.0871 6.96168 14.0145 6.68652 13.9046 6.46566C13.6865 6.02729 13.2819 5.60951 12.8507 5.3776C12.3734 5.12091 11.8075 5.08217 10.1213 5.02848Z\" fill=\"currentColor\"/></g>",
"width": 17 "width": 17
}, },
"app-publisher-deploying-chevron": {
"body": "<g fill=\"none\"><path d=\"M4.13806 0L0 4.13807L0.942807 5.08087L4.13806 1.8856L7.33333 5.08087L8.27613 4.13807L4.13806 0Z\" fill=\"currentColor\"/></g>",
"width": 8.27613,
"height": 5.08087
},
"deploy-code-block": {
"body": "<g fill=\"none\"><path d=\"M2.27624 3.99996L3.80484 2.47137L2.86203 1.52856L0.390625 3.99996L2.86203 6.47137L3.80484 5.52856L2.27624 3.99996ZM6.39063 3.99996L4.86203 2.47137L5.80484 1.52856L8.27627 3.99996L5.80484 6.47137L4.86203 5.52856L6.39063 3.99996ZM9.33347 1.99996H14.0001C14.3683 1.99996 14.6668 2.29844 14.6668 2.66663V13.3333C14.6668 13.7015 14.3683 14 14.0001 14H2.0001C1.63191 14 1.33343 13.7015 1.33343 13.3333V8H2.66677V12.6667H13.3335V3.3333H9.33347V1.99996Z\" fill=\"currentColor\"/></g>"
},
"deploy-line-5": {
"body": "<g fill=\"none\"><path d=\"M10.9062 0.130859L0.482939 38.4953\" stroke=\"currentColor\" stroke-opacity=\"0.04\"/></g>",
"width": 12,
"height": 39
},
"deploy-rocket": {
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M2.91699 9.33317C3.88349 9.33317 4.66699 10.1166 4.66699 11.0832C4.66699 12.0497 3.88349 12.8332 2.91699 12.8332H1.75033C1.42816 12.8332 1.16699 12.572 1.16699 12.2498V11.0832C1.16699 10.1166 1.9505 9.33317 2.91699 9.33317ZM2.91699 10.4998C2.59482 10.4998 2.33366 10.761 2.33366 11.0832V11.6665H2.91699C3.23916 11.6665 3.50033 11.4053 3.50033 11.0832C3.50033 10.761 3.23916 10.4998 2.91699 10.4998Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M12.2503 1.1665C12.5725 1.16651 12.8337 1.42767 12.8337 1.74984C12.8337 4.22292 11.6271 6.07192 9.91699 7.68913V10.4998C9.91699 10.7208 9.79219 10.9228 9.59456 11.0216L6.6779 12.48C6.49712 12.5704 6.28247 12.5606 6.11051 12.4543C5.93854 12.3481 5.83366 12.1603 5.83366 11.9582V10.158L3.84212 8.1665H2.04199C1.83982 8.1665 1.6521 8.06162 1.54582 7.88965C1.4396 7.71769 1.42979 7.50305 1.52018 7.32227L2.97852 4.4056C3.07733 4.20798 3.27938 4.08317 3.50033 4.08317H6.31104C7.92825 2.37309 9.77724 1.1665 12.2503 1.1665ZM7.00033 10.186V11.0142L8.75033 10.1392V8.698L7.00033 10.186ZM11.6362 2.36336C9.82895 2.54598 8.38869 3.53818 6.99463 5.05843L4.87606 7.5507L6.44889 9.12354L8.94116 7.00496C10.4616 5.61077 11.4537 4.17089 11.6362 2.36336ZM2.98592 6.99984H3.81421L5.3016 5.24984H3.86092L2.98592 6.99984Z\" fill=\"currentColor\"/></g>",
"width": 14,
"height": 14
},
"features-citations": { "features-citations": {
"body": "<g fill=\"none\"><path d=\"M1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12ZM7 11.9702V14.958H11.0356V11.2339H8.8125C8.78418 10.8185 8.85498 10.4173 9.0249 10.0303C9.35531 9.29395 10.002 8.77474 10.9648 8.47266V7C9.67155 7.25488 8.68506 7.79297 8.00537 8.61426C7.33512 9.43555 7 10.5542 7 11.9702ZM15.0391 10.0586C15.3695 9.29395 16.0114 8.7653 16.9648 8.47266V7C15.7093 7.25488 14.7323 7.78825 14.0337 8.6001C13.3446 9.41195 13 10.5353 13 11.9702V14.958H17.0356V11.2339H14.8125C14.7747 10.8563 14.8503 10.4645 15.0391 10.0586Z\" fill=\"currentColor\"/></g>", "body": "<g fill=\"none\"><path d=\"M1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12ZM7 11.9702V14.958H11.0356V11.2339H8.8125C8.78418 10.8185 8.85498 10.4173 9.0249 10.0303C9.35531 9.29395 10.002 8.77474 10.9648 8.47266V7C9.67155 7.25488 8.68506 7.79297 8.00537 8.61426C7.33512 9.43555 7 10.5542 7 11.9702ZM15.0391 10.0586C15.3695 9.29395 16.0114 8.7653 16.9648 8.47266V7C15.7093 7.25488 14.7323 7.78825 14.0337 8.6001C13.3446 9.41195 13 10.5353 13 11.9702V14.958H17.0356V11.2339H14.8125C14.7747 10.8563 14.8503 10.4645 15.0391 10.0586Z\" fill=\"currentColor\"/></g>",
"width": 24, "width": 24,
+7 -7
View File
@@ -1,4 +1,4 @@
export type IconifyJSON = { export interface IconifyJSON {
prefix: string prefix: string
icons: Record<string, IconifyIcon> icons: Record<string, IconifyIcon>
aliases?: Record<string, IconifyAlias> aliases?: Record<string, IconifyAlias>
@@ -7,7 +7,7 @@ export type IconifyJSON = {
lastModified?: number lastModified?: number
} }
export type IconifyIcon = { export interface IconifyIcon {
body: string body: string
left?: number left?: number
top?: number top?: number
@@ -18,11 +18,11 @@ export type IconifyIcon = {
vFlip?: boolean vFlip?: boolean
} }
export type IconifyAlias = { export interface IconifyAlias extends Omit<IconifyIcon, 'body'> {
parent: string parent: string
} & Omit<IconifyIcon, 'body'> }
export type IconifyInfo = { export interface IconifyInfo {
prefix: string prefix: string
name: string name: string
total: number total: number
@@ -40,11 +40,11 @@ export type IconifyInfo = {
palette?: boolean palette?: boolean
} }
export type IconifyMetaData = { export interface IconifyMetaData {
[key: string]: unknown [key: string]: unknown
} }
export type IconifyChars = { export interface IconifyChars {
[key: string]: string [key: string]: string
} }
@@ -1,8 +1,8 @@
'use strict' 'use strict'
const chars = require('./chars.json')
const icons = require('./icons.json') const icons = require('./icons.json')
const info = require('./info.json') const info = require('./info.json')
const metadata = require('./metadata.json') const metadata = require('./metadata.json')
const chars = require('./chars.json')
module.exports = { icons, info, metadata, chars } module.exports = { icons, info, metadata, chars }
@@ -1,7 +1,7 @@
{ {
"prefix": "custom-vender", "prefix": "custom-vender",
"name": "Dify Custom Vender", "name": "Dify Custom Vender",
"total": 331, "total": 335,
"version": "0.0.0-private", "version": "0.0.0-private",
"author": { "author": {
"name": "LangGenius, Inc.", "name": "LangGenius, Inc.",
+8 -2
View File
@@ -95,7 +95,10 @@ describe('embedded user id propagation in authentication flows', () => {
}) })
}) })
expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('login-token') expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('login-token')
expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token') expect(setWebAppPassportMock).toHaveBeenCalledWith(
{ kind: 'default', code: 'test-app' },
'passport-token',
)
expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app') expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app')
}) })
@@ -166,7 +169,10 @@ describe('embedded user id propagation in authentication flows', () => {
}) })
}) })
expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('code-token') expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('code-token')
expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token') expect(setWebAppPassportMock).toHaveBeenCalledWith(
{ kind: 'default', code: 'test-app' },
'passport-token',
)
expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app') expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app')
}) })
@@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppAccessPointDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}
@@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppDeployDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}
@@ -185,6 +185,59 @@ describe('AppDetailLayout', () => {
expect(useStore.getState().appDetail?.id).toBe('app-1') expect(useStore.getState().appDetail?.id).toBe('app-1')
}) })
it('should allow access point pages without app deploy or app ACL permissions', async () => {
mockPathname = '/app/app-1/access-point'
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] }))
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitForAppContent()
expect(mockReplace).not.toHaveBeenCalled()
expect(useStore.getState().appDetail?.id).toBe('app-1')
})
it('should redirect deploy pages when app deploy ACL permission is missing', async () => {
mockPathname = '/app/app-1/deploy'
mockFetchAppDetailDirect.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }),
)
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/workflow')
})
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
expect(useStore.getState().appDetail).toBeUndefined()
})
it('should allow users with app deploy ACL permission to open deploy directly', async () => {
mockPathname = '/app/app-1/deploy'
mockFetchAppDetailDirect.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.Deploy] }),
)
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitForAppContent()
expect(mockReplace).not.toHaveBeenCalled()
expect(useStore.getState().appDetail?.id).toBe('app-1')
})
it('should allow users with layout access to open workflow pages directly', async () => { it('should allow users with layout access to open workflow pages directly', async () => {
mockPathname = '/app/app-1/workflow' mockPathname = '/app/app-1/workflow'
@@ -211,7 +264,7 @@ describe('AppDetailLayout', () => {
) )
await waitFor(() => { await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/develop') expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point')
}) })
expect(screen.queryByText('App page content')).not.toBeInTheDocument() expect(screen.queryByText('App page content')).not.toBeInTheDocument()
expect(useStore.getState().appDetail).toBeUndefined() expect(useStore.getState().appDetail).toBeUndefined()
@@ -336,7 +389,7 @@ describe('AppDetailLayout', () => {
) )
await waitFor(() => { await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/develop') expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point')
}) })
expect(screen.queryByText('App page content')).not.toBeInTheDocument() expect(screen.queryByText('App page content')).not.toBeInTheDocument()
expect(useStore.getState().appDetail).toBeUndefined() expect(useStore.getState().appDetail).toBeUndefined()
@@ -0,0 +1,11 @@
import AccessPoint from '@/app/components/app/access-point'
type AppAccessPointPageProps = {
params: Promise<{ appId: string }>
}
export default async function AppAccessPointPage({ params }: AppAccessPointPageProps) {
const { appId } = await params
return <AccessPoint appId={appId} />
}
@@ -0,0 +1,5 @@
import AppDeploy from '@/app/components/app/deploy'
export default function AppDeployPage() {
return <AppDeploy />
}
@@ -120,12 +120,15 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const isAnnotationsPath = pathname.endsWith('annotations') const isAnnotationsPath = pathname.endsWith('annotations')
const isOverviewPath = pathname.endsWith('overview') const isOverviewPath = pathname.endsWith('overview')
const isAccessConfigPath = pathname.endsWith('access-config') const isAccessConfigPath = pathname.endsWith('access-config')
const isDeployPath = pathname.endsWith('deploy')
if ( if (
(isLayoutPath && !appACLCapabilities.canAccessLayout) || (isLayoutPath && !appACLCapabilities.canAccessLayout) ||
(isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) || (isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) ||
(isAnnotationsPath && !appACLCapabilities.canAccessLogAndAnnotation) || (isAnnotationsPath && !appACLCapabilities.canAccessLogAndAnnotation) ||
(isOverviewPath && !appACLCapabilities.canMonitor) || (isOverviewPath && !appACLCapabilities.canMonitor) ||
(isAccessConfigPath && !appACLCapabilities.canAccessConfig) (isAccessConfigPath && !appACLCapabilities.canAccessConfig) ||
(isDeployPath &&
(routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy))
) { ) {
router.replace( router.replace(
getRedirectionPath(routeAppDetail, { getRedirectionPath(routeAppDetail, {
@@ -1,228 +0,0 @@
import type { App } from '@/types/app'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createAccountProfileQueryWrapper } from '@/test/console/account-profile'
import { render as renderWithConsoleState } from '@/test/console/render'
import CardView from '../card-view'
const mockAppState = vi.hoisted(() => ({
appDetail: {
id: 'app-1',
mode: 'chat',
permission_keys: [] as string[],
},
setAppDetail: vi.fn(),
}))
const mockUpdateAppSiteStatus = vi.hoisted(() => vi.fn())
const mockUpdateAppSiteConfig = vi.hoisted(() => vi.fn())
const mockUpdateAppSiteAccessToken = vi.hoisted(() => vi.fn())
const mockFetchAppDetail = vi.hoisted(() => vi.fn())
const mockInvalidateQueries = vi.hoisted(() => vi.fn())
const render = (ui: Parameters<typeof renderWithConsoleState>[0]) =>
renderWithConsoleState(ui, {
wrapper: createAccountProfileQueryWrapper({ id: 'user-1' }),
})
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
}
})
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
return {
...actual,
consoleQuery: {
...actual.consoleQuery,
account: {
profile: {
get: {
queryKey: () => [['console', 'account', 'profile', 'get'], { type: 'query' }],
},
},
},
apps: {
get: { key: () => ['console', 'apps', 'get'] },
starred: { get: { key: () => ['console', 'apps', 'starred', 'get'] } },
recent: { get: { key: () => ['console', 'apps', 'recent', 'get'] } },
},
},
}
})
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: typeof mockAppState) => T): T => selector(mockAppState),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({ data: undefined }),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
updateAppSiteStatus: (...args: unknown[]) => mockUpdateAppSiteStatus(...args),
updateAppSiteConfig: (...args: unknown[]) => mockUpdateAppSiteConfig(...args),
updateAppSiteAccessToken: (...args: unknown[]) => mockUpdateAppSiteAccessToken(...args),
}))
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
workspacePermissionKeys: mockAppState.appDetail.permission_keys,
}))
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
workspacePermissionKeys: mockAppState.appDetail.permission_keys,
}))
})
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: {
onAppStateUpdate: vi.fn(() => vi.fn()),
},
}))
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
webSocketClient: {
getSocket: vi.fn(() => null),
},
}))
vi.mock('@/app/components/app/overview/app-card', () => ({
default: ({
cardType,
onChangeStatus,
onGenerateCode,
onSaveSiteConfig,
}: {
cardType: string
onChangeStatus?: (value: boolean) => void
onGenerateCode?: () => void
onSaveSiteConfig?: (params: Record<string, unknown>) => void
}) => (
<div>
<button type="button" onClick={() => onChangeStatus?.(true)}>
toggle {cardType}
</button>
{onGenerateCode && (
<button type="button" onClick={() => onGenerateCode()}>
generate {cardType}
</button>
)}
{onSaveSiteConfig && (
<button type="button" onClick={() => onSaveSiteConfig({ title: 'Site title' })}>
save {cardType}
</button>
)}
</div>
),
}))
vi.mock('@/app/components/app/overview/trigger-card', () => ({
default: () => <div>trigger card</div>,
}))
vi.mock('@/app/components/tools/mcp/mcp-service-card', () => ({
default: () => <div>mcp card</div>,
}))
describe('CardView ACL edit guards', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppState.appDetail = {
id: 'app-1',
mode: 'chat',
permission_keys: [],
}
mockUpdateAppSiteStatus.mockResolvedValue(mockAppState.appDetail as App)
mockUpdateAppSiteConfig.mockResolvedValue(mockAppState.appDetail as App)
mockUpdateAppSiteAccessToken.mockResolvedValue({ code: 'token' })
mockFetchAppDetail.mockResolvedValue({
id: 'app-1',
mode: 'chat',
permission_keys: ['app.acl.edit'],
site: {
title: 'Saved site title',
},
} as unknown as App)
})
// User-facing card actions should not mutate app settings without app ACL edit permission.
describe('Permissions', () => {
it('should not call write APIs when app ACL edit permission is missing', async () => {
const user = userEvent.setup()
render(<CardView appId="app-1" />)
await user.click(screen.getByRole('button', { name: /toggle webapp/ }))
await user.click(screen.getByRole('button', { name: /save webapp/ }))
await user.click(screen.getByRole('button', { name: /generate webapp/ }))
await user.click(screen.getByRole('button', { name: /toggle api/ }))
expect(mockUpdateAppSiteStatus).not.toHaveBeenCalled()
expect(mockUpdateAppSiteConfig).not.toHaveBeenCalled()
expect(mockUpdateAppSiteAccessToken).not.toHaveBeenCalled()
expect(mockFetchAppDetail).not.toHaveBeenCalled()
})
it('should call write APIs when app ACL edit permission is present', async () => {
const user = userEvent.setup()
mockAppState.appDetail.permission_keys = ['app.acl.edit']
render(<CardView appId="app-1" />)
await user.click(screen.getByRole('button', { name: /toggle webapp/ }))
await user.click(screen.getByRole('button', { name: /save webapp/ }))
await user.click(screen.getByRole('button', { name: /generate webapp/ }))
await user.click(screen.getByRole('button', { name: /toggle api/ }))
await waitFor(() => {
expect(mockUpdateAppSiteStatus).toHaveBeenCalledTimes(2)
})
expect(mockUpdateAppSiteStatus).toHaveBeenCalledWith({
url: '/apps/app-1/site-enable',
body: { enable_site: true },
})
expect(mockUpdateAppSiteStatus).toHaveBeenCalledWith({
url: '/apps/app-1/api-enable',
body: { enable_api: true },
})
expect(mockUpdateAppSiteConfig).toHaveBeenCalledWith({
url: '/apps/app-1/site',
body: { title: 'Site title' },
})
expect(mockUpdateAppSiteAccessToken).toHaveBeenCalledWith({
url: '/apps/app-1/site/access-token-reset',
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['console', 'apps', 'get'],
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['console', 'apps', 'starred', 'get'],
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['console', 'apps', 'recent', 'get'],
})
await waitFor(() => {
expect(mockFetchAppDetail).toHaveBeenCalled()
})
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockAppState.setAppDetail).toHaveBeenCalledWith(
expect.objectContaining({
site: expect.objectContaining({ title: 'Saved site title' }),
}),
)
})
})
})
@@ -1,251 +0,0 @@
'use client'
import type { FC } from 'react'
import type { IAppCardProps } from '@/app/components/app/overview/app-card'
import type { BlockEnum } from '@/app/components/workflow/types'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import type { App } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AppCard from '@/app/components/app/overview/app-card'
import TriggerCard from '@/app/components/app/overview/trigger-card'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { isTriggerNode } from '@/app/components/workflow/types'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import {
fetchAppDetail,
updateAppSiteAccessToken,
updateAppSiteConfig,
updateAppSiteStatus,
} from '@/service/apps'
import { consoleQuery } from '@/service/client'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
import { getAppACLCapabilities } from '@/utils/permission'
type ICardViewProps = {
appId: string
isInPanel?: boolean
className?: string
}
const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { t } = useTranslation()
const queryClient = useQueryClient()
const appDetail = useAppStore((state) => state.appDetail)
const setAppDetail = useAppStore((state) => state.setAppDetail)
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canEditApp = useMemo(
() =>
getAppACLCapabilities(appDetail?.permission_keys, {
currentUserId,
resourceMaintainer: appDetail?.maintainer,
workspacePermissionKeys,
}).canEdit,
[appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys],
)
const isWorkflowApp = appDetail?.mode === AppModeEnum.WORKFLOW
const showMCPCard = isInPanel
const showTriggerCard = isInPanel && isWorkflowApp
const { data: currentWorkflow } = useAppWorkflow(isWorkflowApp ? appDetail.id : '')
const hasTriggerNode = useMemo<boolean | null>(() => {
if (!isWorkflowApp) return false
if (!currentWorkflow) return null
const nodes = currentWorkflow.graph?.nodes || []
return nodes.some((node) => {
const nodeType = node.data?.type as BlockEnum | undefined
return !!nodeType && isTriggerNode(nodeType)
})
}, [isWorkflowApp, currentWorkflow])
const shouldRenderAppCards = !isWorkflowApp || hasTriggerNode === false
const disableAppCards = !shouldRenderAppCards
const buildTriggerModeMessage = useCallback(
(featureName: string) => (
<div className="flex flex-col gap-1">
<div className="text-xs text-text-secondary">
{t(($) => $['overview.disableTooltip.triggerMode'], {
ns: 'appOverview',
feature: featureName,
})}
</div>
</div>
),
[t],
)
const disableWebAppTooltip = disableAppCards
? buildTriggerModeMessage(t(($) => $['overview.appInfo.title'], { ns: 'appOverview' }))
: null
const disableApiTooltip = disableAppCards
? buildTriggerModeMessage(t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' }))
: null
const disableMcpTooltip = disableAppCards
? buildTriggerModeMessage(t(($) => $['mcp.server.title'], { ns: 'tools' }))
: null
const updateAppDetail = useCallback(async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setAppDetail({ ...res })
} catch (error) {
console.error(error)
}
}, [appId, setAppDetail])
const handleCallbackResult = (
err: Error | null,
message?: I18nKeysByPrefix<'common', 'actionMsg.'>,
) => {
const type = err ? 'error' : 'success'
message ||= type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully'
if (type === 'success') {
updateAppDetail()
// Emit collaboration event to notify other clients of app state changes
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'app_state_update',
data: { timestamp: Date.now() },
timestamp: Date.now(),
})
}
}
toast(t(($) => $[`actionMsg.${message}`], { ns: 'common' }) as string, { type })
}
// Listen for collaborative app state updates from other clients
useEffect(() => {
if (!appId) return
const unsubscribe = collaborationManager.onAppStateUpdate(async () => {
try {
// Update app detail when other clients modify app state
await updateAppDetail()
} catch (error) {
console.error('app state update failed:', error)
}
})
return unsubscribe
}, [appId, updateAppDetail])
const onChangeSiteStatus = async (value: boolean) => {
if (!canEditApp) return
const [err] = await asyncRunSafe<App>(
updateAppSiteStatus({
url: `/apps/${appId}/site-enable`,
body: { enable_site: value },
}) as Promise<App>,
)
handleCallbackResult(err)
}
const onChangeApiStatus = async (value: boolean) => {
if (!canEditApp) return
const [err] = await asyncRunSafe<App>(
updateAppSiteStatus({
url: `/apps/${appId}/api-enable`,
body: { enable_api: value },
}) as Promise<App>,
)
handleCallbackResult(err)
}
const onSaveSiteConfig: IAppCardProps['onSaveSiteConfig'] = async (params) => {
if (!canEditApp) return
const [err] = await asyncRunSafe<App>(
updateAppSiteConfig({
url: `/apps/${appId}/site`,
body: params,
}) as Promise<App>,
)
if (!err) {
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() })
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() })
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() })
}
handleCallbackResult(err)
}
const onGenerateCode = async () => {
if (!canEditApp) return
const [err] = await asyncRunSafe<UpdateAppSiteCodeResponse>(
updateAppSiteAccessToken({
url: `/apps/${appId}/site/access-token-reset`,
}) as Promise<UpdateAppSiteCodeResponse>,
)
handleCallbackResult(err, err ? 'generatedUnsuccessfully' : 'generatedSuccessfully')
}
if (!appDetail) return <Loading />
const appCards = (
<>
<AppCard
appInfo={appDetail}
cardType="webapp"
isInPanel={isInPanel}
triggerModeDisabled={disableAppCards}
triggerModeMessage={disableWebAppTooltip}
onChangeStatus={onChangeSiteStatus}
onGenerateCode={onGenerateCode}
onSaveSiteConfig={onSaveSiteConfig}
/>
<AppCard
cardType="api"
appInfo={appDetail}
isInPanel={isInPanel}
triggerModeDisabled={disableAppCards}
triggerModeMessage={disableApiTooltip}
onChangeStatus={onChangeApiStatus}
/>
{showMCPCard && (
<MCPServiceCard
appInfo={appDetail}
triggerModeDisabled={disableAppCards}
triggerModeMessage={disableMcpTooltip}
/>
)}
</>
)
const triggerCardNode = showTriggerCard ? (
<TriggerCard appInfo={appDetail} onToggleResult={handleCallbackResult} />
) : null
return (
<div className={className || 'mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'}>
{disableAppCards && triggerCardNode}
{appCards}
{!disableAppCards && triggerCardNode}
</div>
)
}
export default CardView
@@ -1,59 +1,22 @@
import type { ReactElement, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
ensureQueryData: vi.fn(),
systemFeaturesQueryOptions: { queryKey: ['console', 'system-features'] },
notFound: vi.fn(() => { notFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND') throw new Error('NEXT_NOT_FOUND')
}), }),
})) }))
vi.mock('@/features/system-features/server', () => ({
getSystemFeaturesQueryClient: () => ({
ensureQueryData: mocks.ensureQueryData,
}),
systemFeaturesServerQueryOptions: () => mocks.systemFeaturesQueryOptions,
}))
vi.mock('@/features/deployments/deploy-drawer', () => ({
DeployDrawer: () => <div>Deploy drawer</div>,
}))
vi.mock('@/next/navigation', () => ({ vi.mock('@/next/navigation', () => ({
notFound: () => mocks.notFound(), notFound: () => mocks.notFound(),
})) }))
const renderDeploymentsLayout = async (children: ReactNode) => {
const { default: DeploymentsLayout } = await import('../layout')
const element = await DeploymentsLayout({ children })
render(element as ReactElement)
}
describe('DeploymentsLayout', () => { describe('DeploymentsLayout', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mocks.ensureQueryData.mockResolvedValue({ enable_app_deploy: true })
}) })
it('should render deployments content and drawer when app deploy is enabled', async () => { it('should always trigger notFound', async () => {
await renderDeploymentsLayout(<div>Deployments content</div>)
expect(mocks.ensureQueryData).toHaveBeenCalledWith(mocks.systemFeaturesQueryOptions)
expect(screen.getByText('Deployments content')).toBeInTheDocument()
expect(screen.getByText('Deploy drawer')).toBeInTheDocument()
expect(mocks.notFound).not.toHaveBeenCalled()
})
it('should trigger notFound when app deploy is disabled', async () => {
mocks.ensureQueryData.mockResolvedValue({ enable_app_deploy: false })
const { default: DeploymentsLayout } = await import('../layout') const { default: DeploymentsLayout } = await import('../layout')
await expect( expect(() => DeploymentsLayout()).toThrow('NEXT_NOT_FOUND')
DeploymentsLayout({
children: <div>Deployments content</div>,
}),
).rejects.toThrow('NEXT_NOT_FOUND')
expect(mocks.notFound).toHaveBeenCalledTimes(1) expect(mocks.notFound).toHaveBeenCalledTimes(1)
}) })
+2 -19
View File
@@ -1,22 +1,5 @@
import type { ReactNode } from 'react'
import { DeployDrawer } from '@/features/deployments/deploy-drawer'
import {
getSystemFeaturesQueryClient,
systemFeaturesServerQueryOptions,
} from '@/features/system-features/server'
import { notFound } from '@/next/navigation' import { notFound } from '@/next/navigation'
export default async function DeploymentsLayout({ children }: { children: ReactNode }) { export default function DeploymentsLayout() {
const systemFeatures = await getSystemFeaturesQueryClient().ensureQueryData( notFound()
systemFeaturesServerQueryOptions(),
)
if (!systemFeatures.enable_app_deploy) notFound()
return (
<>
{children}
<DeployDrawer />
</>
)
} }
@@ -1,4 +1,5 @@
import type { AppData, AppMeta } from '@/models/share' import type { AppData, AppMeta } from '@/models/share'
import type { WebAppAddress } from '@/service/webapp-address'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { webAppLogout } from '@/service/webapp-auth' import { webAppLogout } from '@/service/webapp-auth'
@@ -20,6 +21,7 @@ const updateAppParams = vi.fn()
const updateWebAppMeta = vi.fn() const updateWebAppMeta = vi.fn()
const updateUserCanAccessApp = vi.fn() const updateUserCanAccessApp = vi.fn()
const replace = vi.fn() const replace = vi.fn()
const webAppAddress: WebAppAddress = { kind: 'default', code: 'share-code' }
const mockWebAppState = { const mockWebAppState = {
shareCode: 'share-code', shareCode: 'share-code',
@@ -96,6 +98,10 @@ vi.mock('@/service/webapp-auth', () => ({
webAppLogout: vi.fn(), webAppLogout: vi.fn(),
})) }))
vi.mock('@/service/webapp-address', () => ({
resolveWebAppAddress: () => webAppAddress,
}))
const resetQueryStates = () => { const resetQueryStates = () => {
appInfoQueryState.data = { appInfoQueryState.data = {
app_id: 'app-id', app_id: 'app-id',
@@ -173,7 +179,7 @@ describe('AuthenticatedLayout', () => {
await user.click(screen.getByRole('button', { name: 'common.userProfile.logout' })) await user.click(screen.getByRole('button', { name: 'common.userProfile.logout' }))
expect(webAppLogout).toHaveBeenCalledWith('share-code') expect(webAppLogout).toHaveBeenCalledWith(webAppAddress)
expect(replace).toHaveBeenCalledWith('/webapp-signin?redirect_url=%2Fworkflow%2Fshare-code') expect(replace).toHaveBeenCalledWith('/webapp-signin?redirect_url=%2Fworkflow%2Fshare-code')
}) })
}) })
@@ -47,6 +47,7 @@ describe('Splash', () => {
vi.clearAllMocks() vi.clearAllMocks()
webAppState.shareCode = 'share-app' webAppState.shareCode = 'share-app'
navigationMocks.pathname = '/chatbot/share-app' navigationMocks.pathname = '/chatbot/share-app'
window.history.replaceState({}, '', navigationMocks.pathname)
navigationMocks.searchParams = new URLSearchParams({ navigationMocks.searchParams = new URLSearchParams({
redirect_url: 'https://evil.example/chatbot/evil-app', redirect_url: 'https://evil.example/chatbot/evil-app',
}) })
@@ -9,11 +9,11 @@ import { useWebAppStore } from '@/context/web-app-context'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share' import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share'
import { resolveWebAppAddress } from '@/service/webapp-address'
import { webAppLogout } from '@/service/webapp-auth' import { webAppLogout } from '@/service/webapp-auth'
const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation() const { t } = useTranslation()
const shareCode = useWebAppStore((s) => s.shareCode)
const updateAppInfo = useWebAppStore((s) => s.updateAppInfo) const updateAppInfo = useWebAppStore((s) => s.updateAppInfo)
const updateAppParams = useWebAppStore((s) => s.updateAppParams) const updateAppParams = useWebAppStore((s) => s.updateAppParams)
const updateWebAppMeta = useWebAppStore((s) => s.updateWebAppMeta) const updateWebAppMeta = useWebAppStore((s) => s.updateWebAppMeta)
@@ -59,10 +59,10 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
}, [searchParams, pathname]) }, [searchParams, pathname])
const backToHome = useCallback(async () => { const backToHome = useCallback(async () => {
await webAppLogout(shareCode!) await webAppLogout(resolveWebAppAddress())
const url = getSigninUrl() const url = getSigninUrl()
router.replace(url) router.replace(url)
}, [getSigninUrl, router, shareCode]) }, [getSigninUrl, router])
if (appInfoError) { if (appInfoError) {
return ( return (
+12 -9
View File
@@ -11,6 +11,7 @@ import Loading from '@/app/components/base/loading'
import { useWebAppStore } from '@/context/web-app-context' import { useWebAppStore } from '@/context/web-app-context'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { fetchAccessToken } from '@/service/share' import { fetchAccessToken } from '@/service/share'
import { resolveWebAppAddress } from '@/service/webapp-address'
import { import {
setWebAppAccessToken, setWebAppAccessToken,
setWebAppPassport, setWebAppPassport,
@@ -45,16 +46,16 @@ function Splash({ children }: PropsWithChildren) {
const backToHome = useCallback(async () => { const backToHome = useCallback(async () => {
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin) const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
const effectiveShareCode = loginRedirect?.appCode || shareCode const address = loginRedirect?.address || resolveWebAppAddress()
if (!effectiveShareCode || (isWebAppSigninPath(pathname) && !loginRedirect)) { if (!address || (isWebAppSigninPath(pathname) && !loginRedirect)) {
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath) replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
return return
} }
await webAppLogout(effectiveShareCode) await webAppLogout(address)
const url = getSigninUrl() const url = getSigninUrl()
router.replace(url) router.replace(url)
}, [getSigninUrl, pathname, redirectUrl, router, shareCode]) }, [getSigninUrl, pathname, redirectUrl, router])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [unavailableShareCode, setUnavailableShareCode] = useState<string>() const [unavailableShareCode, setUnavailableShareCode] = useState<string>()
@@ -66,8 +67,9 @@ function Splash({ children }: PropsWithChildren) {
return return
} }
const effectiveShareCode = loginRedirect?.appCode || shareCode const address = loginRedirect?.address || resolveWebAppAddress()
if (!effectiveShareCode) return if (!address) return
const effectiveShareCode = address.code
if (message) return if (message) return
@@ -86,6 +88,7 @@ function Splash({ children }: PropsWithChildren) {
// if access mode is public, user login is always true, but the app login(passport) may be expired // if access mode is public, user login is always true, but the app login(passport) may be expired
const { userLoggedIn, appLoggedIn } = await webAppLoginStatus( const { userLoggedIn, appLoggedIn } = await webAppLoginStatus(
effectiveShareCode, effectiveShareCode,
webAppAccessMode,
embeddedUserId || undefined, embeddedUserId || undefined,
) )
if (userLoggedIn && appLoggedIn) { if (userLoggedIn && appLoggedIn) {
@@ -100,15 +103,15 @@ function Splash({ children }: PropsWithChildren) {
appCode: effectiveShareCode, appCode: effectiveShareCode,
userId: embeddedUserId || undefined, userId: embeddedUserId || undefined,
}) })
setWebAppPassport(effectiveShareCode, access_token) setWebAppPassport(address, access_token)
redirectOrFinish() redirectOrFinish()
} catch (error) { } catch (error) {
if (error instanceof Response && error.status === 404) { if (error instanceof Response && error.status === 404) {
setUnavailableShareCode(effectiveShareCode) setUnavailableShareCode(effectiveShareCode)
await webAppLogout(effectiveShareCode) await webAppLogout(address)
return return
} }
await webAppLogout(effectiveShareCode) await webAppLogout(address)
proceedToAuth() proceedToAuth()
} }
} }
+13
View File
@@ -0,0 +1,13 @@
import * as React from 'react'
import Main from '@/app/components/share/text-generation'
import AuthenticatedLayout from '../../../components/authenticated-layout'
const EnvironmentWorkflow = () => {
return (
<AuthenticatedLayout>
<Main isWorkflow />
</AuthenticatedLayout>
)
}
export default React.memo(EnvironmentWorkflow)
@@ -11,6 +11,7 @@ describe('resolveWebAppLoginRedirect', () => {
expect(result).toEqual({ expect(result).toEqual({
appCode: 'share-app', appCode: 'share-app',
address: { kind: 'default', code: 'share-app' },
target: { kind: 'internal', href: '/chatbot/share-app?foo=bar#answer' }, target: { kind: 'internal', href: '/chatbot/share-app?foo=bar#answer' },
}) })
}) })
@@ -23,6 +24,19 @@ describe('resolveWebAppLoginRedirect', () => {
expect(result?.target.href).toBe(redirectUrl) expect(result?.target.href).toBe(redirectUrl)
expect(result?.appCode).toBe('share-app') expect(result?.appCode).toBe('share-app')
}) })
it('should resolve an environment workflow redirect', () => {
const result = resolveWebAppLoginRedirect(
'/env/workflow/workflow-app',
'https://self-hosted.example.com',
)
expect(result).toEqual({
appCode: 'workflow-app',
address: { kind: 'environment', code: 'workflow-app' },
target: { kind: 'internal', href: '/env/workflow/workflow-app' },
})
})
}) })
// Covers absolute destinations accepted by the shared login redirect policy. // Covers absolute destinations accepted by the shared login redirect policy.
@@ -35,6 +49,7 @@ describe('resolveWebAppLoginRedirect', () => {
expect(result).toEqual({ expect(result).toEqual({
appCode: 'share-app', appCode: 'share-app',
address: { kind: 'default', code: 'share-app' },
target: { target: {
kind: 'absolute', kind: 'absolute',
href: 'http://self-hosted.example.com:8080/chatbot/share-app', href: 'http://self-hosted.example.com:8080/chatbot/share-app',
@@ -1,3 +1,4 @@
import { zSsoProtocol } from '@dify/contracts/api/console/system-features/zod.gen'
import { screen, waitFor } from '@testing-library/react' import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
@@ -6,13 +7,20 @@ import { renderWithConsoleQuery } from '@/test/console/query-data'
import WebSSOForm from '../page' import WebSSOForm from '../page'
const navigationMocks = vi.hoisted(() => ({ const navigationMocks = vi.hoisted(() => ({
push: vi.fn(),
replace: vi.fn(), replace: vi.fn(),
searchParams: new URLSearchParams(), searchParams: new URLSearchParams(),
})) }))
const serviceMocks = vi.hoisted(() => ({
fetchWebOAuth2SSOUrl: vi.fn(),
fetchWebOIDCSSOUrl: vi.fn(),
fetchWebSAMLSSOUrl: vi.fn(),
}))
const webAppState = { const webAppState = {
shareCode: 'share-app', shareCode: 'share-app',
webAppAccessMode: AccessMode.PUBLIC, webAppAccessMode: AccessMode.PUBLIC as AccessMode,
} }
vi.mock('@/context/web-app-context', () => ({ vi.mock('@/context/web-app-context', () => ({
@@ -20,14 +28,20 @@ vi.mock('@/context/web-app-context', () => ({
})) }))
vi.mock('@/next/navigation', () => ({ vi.mock('@/next/navigation', () => ({
useRouter: () => ({ replace: navigationMocks.replace }), useRouter: () => ({ push: navigationMocks.push, replace: navigationMocks.replace }),
useSearchParams: () => navigationMocks.searchParams, useSearchParams: () => navigationMocks.searchParams,
})) }))
vi.mock('@/service/share', () => serviceMocks)
vi.mock('@/service/webapp-auth', () => ({ vi.mock('@/service/webapp-auth', () => ({
webAppLogout: vi.fn(), webAppLogout: vi.fn(),
})) }))
afterEach(() => {
window.history.replaceState({}, '', '/')
})
describe('WebSSOForm redirect security', () => { describe('WebSSOForm redirect security', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -51,6 +65,7 @@ describe('WebSSOForm redirect security', () => {
navigationMocks.searchParams = new URLSearchParams({ navigationMocks.searchParams = new URLSearchParams({
redirect_url: encodeURIComponent('/chatbot/share-app'), redirect_url: encodeURIComponent('/chatbot/share-app'),
}) })
window.history.replaceState({}, '', '/webapp-signin?redirect_url=%2Fchatbot%2Fshare-app')
renderWithConsoleQuery(<WebSSOForm />, { renderWithConsoleQuery(<WebSSOForm />, {
systemFeatures: { webapp_auth: { enabled: true } }, systemFeatures: { webapp_auth: { enabled: true } },
@@ -58,9 +73,41 @@ describe('WebSSOForm redirect security', () => {
await user.click(await screen.findByRole('button', { name: 'share.login.backToHome' })) await user.click(await screen.findByRole('button', { name: 'share.login.backToHome' }))
expect(webAppLogout).toHaveBeenCalledWith('share-app') expect(webAppLogout).toHaveBeenCalledWith({ kind: 'default', code: 'share-app' })
expect(navigationMocks.replace).toHaveBeenCalledWith( expect(navigationMocks.replace).toHaveBeenCalledWith(
'/webapp-signin?redirect_url=%2Fchatbot%2Fshare-app', '/webapp-signin?redirect_url=%2Fchatbot%2Fshare-app',
) )
}) })
}) })
describe('WebSSOForm environment access modes', () => {
beforeEach(() => {
vi.clearAllMocks()
navigationMocks.searchParams = new URLSearchParams({
redirect_url: '/env/workflow/workflow-app',
})
})
afterEach(() => {
webAppState.webAppAccessMode = AccessMode.PUBLIC
})
it('should start web SSO for an sso verified environment webapp', async () => {
webAppState.webAppAccessMode = AccessMode.EXTERNAL_MEMBERS
serviceMocks.fetchWebSAMLSSOUrl.mockResolvedValue({ url: 'https://idp.example/authorize' })
renderWithConsoleQuery(<WebSSOForm />, {
systemFeatures: {
webapp_auth: { enabled: true, sso_config: { protocol: zSsoProtocol.enum.saml } },
},
})
await waitFor(() => {
expect(serviceMocks.fetchWebSAMLSSOUrl).toHaveBeenCalledWith(
'workflow-app',
'/env/workflow/workflow-app',
)
})
expect(navigationMocks.push).toHaveBeenCalledWith('https://idp.example/authorize')
})
})
@@ -66,7 +66,7 @@ export default function CheckCode() {
appCode: loginRedirect.appCode, appCode: loginRedirect.appCode,
userId: embeddedUserId || undefined, userId: embeddedUserId || undefined,
}) })
setWebAppPassport(loginRedirect.appCode, access_token) setWebAppPassport(loginRedirect.address, access_token)
replaceLoginRedirect(loginRedirect.target, router.replace, basePath) replaceLoginRedirect(loginRedirect.target, router.replace, basePath)
} }
} catch (error) { } catch (error) {
@@ -83,7 +83,7 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
appCode: loginRedirect.appCode, appCode: loginRedirect.appCode,
userId: embeddedUserId || undefined, userId: embeddedUserId || undefined,
}) })
setWebAppPassport(loginRedirect.appCode, access_token) setWebAppPassport(loginRedirect.address, access_token)
replaceLoginRedirect(loginRedirect.target, router.replace, basePath) replaceLoginRedirect(loginRedirect.target, router.replace, basePath)
} else { } else {
toast.error(res.data) toast.error(res.data)
@@ -1,10 +1,13 @@
import type { WebAppAddress } from '@/service/webapp-address'
import type { LoginRedirectTarget } from '@/utils/login-redirect' import type { LoginRedirectTarget } from '@/utils/login-redirect'
import { parseWebAppAddress } from '@/service/webapp-address'
import { resolveLoginRedirectTarget } from '@/utils/login-redirect' import { resolveLoginRedirectTarget } from '@/utils/login-redirect'
const INTERNAL_PATH_PARSE_BASE = 'https://login-redirect.invalid' const INTERNAL_PATH_PARSE_BASE = 'https://login-redirect.invalid'
export type WebAppLoginRedirect = { export type WebAppLoginRedirect = {
appCode: string appCode: string
address: WebAppAddress
target: LoginRedirectTarget target: LoginRedirectTarget
} }
@@ -40,10 +43,10 @@ export function resolveWebAppLoginRedirect(
const url = new URL(target.href, currentOrigin || INTERNAL_PATH_PARSE_BASE) const url = new URL(target.href, currentOrigin || INTERNAL_PATH_PARSE_BASE)
if (isWebAppSigninPath(url.pathname)) return null if (isWebAppSigninPath(url.pathname)) return null
const appCode = url.pathname.split('/').filter(Boolean).at(-1) const address = parseWebAppAddress(url.pathname)
if (!appCode) return null if (!address) return null
return { appCode, target } return { appCode: address.code, address, target }
} catch { } catch {
return null return null
} }
+3 -3
View File
@@ -9,6 +9,7 @@ import { useWebAppStore } from '@/context/web-app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import { useRouter, useSearchParams } from '@/next/navigation' import { useRouter, useSearchParams } from '@/next/navigation'
import { resolveWebAppAddress } from '@/service/webapp-address'
import { webAppLogout } from '@/service/webapp-auth' import { webAppLogout } from '@/service/webapp-auth'
import { getClientLoginFallback } from '@/utils/login-redirect' import { getClientLoginFallback } from '@/utils/login-redirect'
import { replaceLoginRedirect } from '@/utils/login-redirect.client' import { replaceLoginRedirect } from '@/utils/login-redirect.client'
@@ -44,12 +45,11 @@ function WebSSOForm() {
return `/webapp-signin?${params.toString()}` return `/webapp-signin?${params.toString()}`
}, [redirectUrl]) }, [redirectUrl])
const shareCode = useWebAppStore((s) => s.shareCode)
const backToHome = useCallback(async () => { const backToHome = useCallback(async () => {
await webAppLogout(shareCode!) await webAppLogout(resolveWebAppAddress())
const url = getSigninUrl() const url = getSigninUrl()
router.replace(url) router.replace(url)
}, [getSigninUrl, router, shareCode]) }, [getSigninUrl, router])
if (!loginRedirect) { if (!loginRedirect) {
return ( return (
@@ -18,6 +18,7 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
renderWithConsoleQuery(ui, { renderWithConsoleQuery(ui, {
systemFeatures: { systemFeatures: {
rbac_enabled: mockIsRbacEnabled, rbac_enabled: mockIsRbacEnabled,
enable_app_deploy: false,
}, },
}) })
@@ -44,7 +45,12 @@ vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState.current) return createPermissionStateModuleMock(() => mockConsoleState.current)
}) })
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({
isCurrentWorkspaceEditor: false,
}))
})
vi.mock('@/next/navigation', () => ({ vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname, usePathname: () => mockPathname,
})) }))
@@ -183,6 +189,58 @@ describe('AppDetailSection', () => {
).not.toBeInTheDocument() ).not.toBeInTheDocument()
}) })
it('should render access point navigation using its app route', () => {
// Act
render(<AppDetailSection />)
// Assert
expect(screen.getByRole('link', { name: 'common.appMenus.accessPoint' })).toHaveAttribute(
'href',
'/app/app-1/access-point',
)
expect(
screen.queryByRole('link', { name: 'common.appMenus.apiAccess' }),
).not.toBeInTheDocument()
})
it('should render deploy navigation with app deploy ACL regardless of the legacy workspace role', () => {
// Arrange
mockAppMode = 'workflow'
mockAppPermissionKeys = [AppACLPermission.Deploy]
// Act
render(<AppDetailSection />)
// Assert
expect(screen.getByRole('link', { name: 'common.appMenus.deploy' })).toHaveAttribute(
'href',
'/app/app-1/deploy',
)
})
it.each([
{
label: 'the app is not a workflow app',
mode: 'chat',
permissionKeys: [AppACLPermission.Deploy],
},
{
label: 'app deploy ACL permission is missing',
mode: 'workflow',
permissionKeys: [AppACLPermission.Monitor],
},
])('should hide deploy navigation when $label', ({ mode, permissionKeys }) => {
// Arrange
mockAppMode = mode
mockAppPermissionKeys = permissionKeys
// Act
render(<AppDetailSection />)
// Assert
expect(screen.queryByRole('link', { name: 'common.appMenus.deploy' })).not.toBeInTheDocument()
})
it('should render resource access navigation when app access config permission is granted', () => { it('should render resource access navigation when app access config permission is granted', () => {
// Arrange // Arrange
mockAppPermissionKeys = [AppACLPermission.AccessConfig] mockAppPermissionKeys = [AppACLPermission.AccessConfig]
@@ -1,104 +0,0 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import AppBasic from '../basic'
vi.mock('@/app/components/base/icons/src/vender/workflow', () => ({
ApiAggregate: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="api-icon" {...props} />,
WindowCursor: (props: React.SVGProps<SVGSVGElement>) => (
<svg data-testid="webapp-icon" {...props} />
),
}))
vi.mock('../../base/app-icon', () => ({
default: ({
icon,
background,
innerIcon,
className,
}: {
icon?: string
background?: string
innerIcon?: React.ReactNode
className?: string
}) => (
<div data-testid="app-icon" data-icon={icon} data-bg={background} className={className}>
{innerIcon}
</div>
),
}))
describe('AppBasic', () => {
describe('Icon rendering', () => {
it('should render app icon when iconType is app with valid icon and background', () => {
render(<AppBasic name="Test" type="Chat" icon="🤖" icon_background="#fff" />)
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
})
it('should not render app icon when icon is empty', () => {
render(<AppBasic name="Test" type="Chat" />)
expect(screen.queryByTestId('app-icon')).not.toBeInTheDocument()
})
it('should render api icon when iconType is api', () => {
render(<AppBasic name="Test" type="API" iconType="api" />)
expect(screen.getByTestId('api-icon')).toBeInTheDocument()
})
it('should render webapp icon when iconType is webapp', () => {
render(<AppBasic name="Test" type="Webapp" iconType="webapp" />)
expect(screen.getByTestId('webapp-icon')).toBeInTheDocument()
})
it('should render dataset icon when iconType is dataset', () => {
render(<AppBasic name="Test" type="Dataset" iconType="dataset" />)
const icons = screen.getAllByTestId('app-icon')
expect(icons.length).toBeGreaterThan(0)
})
it('should render notion icon when iconType is notion', () => {
render(<AppBasic name="Test" type="Notion" iconType="notion" />)
const icons = screen.getAllByTestId('app-icon')
expect(icons.length).toBeGreaterThan(0)
})
})
describe('Expand mode', () => {
it('should show name and type in expand mode', () => {
render(<AppBasic name="My App" type="Chatbot" />)
expect(screen.getByText('My App')).toBeInTheDocument()
expect(screen.getByText('Chatbot')).toBeInTheDocument()
})
it('should hide name and type in collapse mode', () => {
render(<AppBasic name="My App" type="Chatbot" mode="collapse" />)
expect(screen.queryByText('My App')).not.toBeInTheDocument()
})
it('should show hover tip when provided', () => {
render(<AppBasic name="My App" type="Chatbot" hoverTip="Some tip" />)
expect(screen.getByLabelText('Some tip')).toBeInTheDocument()
})
it('should not show hover tip when not provided', () => {
render(<AppBasic name="My App" type="Chatbot" />)
expect(screen.queryByLabelText('Some tip')).not.toBeInTheDocument()
})
})
describe('Type display', () => {
it('should hide type when hideType is true', () => {
render(<AppBasic name="My App" type="Chatbot" hideType />)
expect(screen.queryByText('Chatbot')).not.toBeInTheDocument()
})
it('should show external tag when isExternal is true', () => {
render(<AppBasic name="My App" type="Dataset" isExternal />)
expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
})
it('should show type inline when isExtraInLine is true and hideType is false', () => {
render(<AppBasic name="My App" type="Chatbot" isExtraInLine />)
expect(screen.getByText('Chatbot')).toBeInTheDocument()
})
})
})
@@ -3,18 +3,6 @@
import type { ComponentProps } from 'react' import type { ComponentProps } from 'react'
import type { NavIcon } from './nav-link' import type { NavIcon } from './nav-link'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import {
RiDashboard2Fill,
RiDashboard2Line,
RiFileList3Fill,
RiFileList3Line,
RiLock2Fill,
RiLock2Line,
RiTerminalBoxFill,
RiTerminalBoxLine,
RiTerminalWindowFill,
RiTerminalWindowLine,
} from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query' import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { Fragment, useMemo } from 'react' import { Fragment, useMemo } from 'react'
@@ -45,6 +33,28 @@ const AnnotationNavIcon = ({ className, ...props }: ComponentProps<typeof Annota
AnnotationNavIcon.displayName = 'Annotations' AnnotationNavIcon.displayName = 'Annotations'
const createClassNameNavIcon = (iconClassName: string) => {
const ClassNameNavIcon = ({ className }: ComponentProps<'svg'>) => (
<span aria-hidden className={cn(iconClassName, className)} />
)
ClassNameNavIcon.displayName = 'ClassNameNavIcon'
return ClassNameNavIcon
}
const accessPointNavIcon = createClassNameNavIcon('i-custom-vender-agent-v2-access-point')
const terminalWindowLineNavIcon = createClassNameNavIcon('i-ri-terminal-window-line')
const terminalWindowFillNavIcon = createClassNameNavIcon('i-ri-terminal-window-fill')
const instanceLineNavIcon = createClassNameNavIcon('i-ri-instance-line')
const instanceFillNavIcon = createClassNameNavIcon('i-ri-instance-fill')
const fileListLineNavIcon = createClassNameNavIcon('i-ri-file-list-3-line')
const fileListFillNavIcon = createClassNameNavIcon('i-ri-file-list-3-fill')
const dashboardLineNavIcon = createClassNameNavIcon('i-ri-dashboard-2-line')
const dashboardFillNavIcon = createClassNameNavIcon('i-ri-dashboard-2-fill')
const lockLineNavIcon = createClassNameNavIcon('i-ri-lock-2-line')
const lockFillNavIcon = createClassNameNavIcon('i-ri-lock-2-fill')
const isLogsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/logs') const isLogsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/logs')
const isAnnotationsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/annotations') const isAnnotationsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/annotations')
@@ -88,6 +98,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
const appId = appDetail.id const appId = appDetail.id
const isWorkflowApp = const isWorkflowApp =
appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT
const supportsAppDeploy = appDetail.mode === AppModeEnum.WORKFLOW
const supportsAnnotations = const supportsAnnotations =
appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION
const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, { const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, {
@@ -103,24 +114,34 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
{ {
name: t(($) => $['appMenus.promptEng'], { ns: 'common' }), name: t(($) => $['appMenus.promptEng'], { ns: 'common' }),
href: `/app/${appId}/${isWorkflowApp ? 'workflow' : 'configuration'}`, href: `/app/${appId}/${isWorkflowApp ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine, icon: terminalWindowLineNavIcon,
selectedIcon: RiTerminalWindowFill, selectedIcon: terminalWindowFillNavIcon,
}, },
] ]
: []), : []),
{ {
name: t(($) => $['appMenus.apiAccess'], { ns: 'common' }), name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }),
href: `/app/${appId}/develop`, href: `/app/${appId}/access-point`,
icon: RiTerminalBoxLine, icon: accessPointNavIcon,
selectedIcon: RiTerminalBoxFill, selectedIcon: accessPointNavIcon,
}, },
...(supportsAppDeploy && appACLCapabilities.canDeploy
? [
{
name: t(($) => $['appMenus.deploy'], { ns: 'common' }),
href: `/app/${appId}/deploy`,
icon: instanceLineNavIcon,
selectedIcon: instanceFillNavIcon,
},
]
: []),
...(appACLCapabilities.canAccessLogAndAnnotation ...(appACLCapabilities.canAccessLogAndAnnotation
? [ ? [
{ {
name: t(($) => $['appMenus.logs'], { ns: 'common' }), name: t(($) => $['appMenus.logs'], { ns: 'common' }),
href: `/app/${appId}/logs`, href: `/app/${appId}/logs`,
icon: RiFileList3Line, icon: fileListLineNavIcon,
selectedIcon: RiFileList3Fill, selectedIcon: fileListFillNavIcon,
}, },
] ]
: []), : []),
@@ -139,8 +160,8 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
{ {
name: t(($) => $['appMenus.overview'], { ns: 'common' }), name: t(($) => $['appMenus.overview'], { ns: 'common' }),
href: `/app/${appId}/overview`, href: `/app/${appId}/overview`,
icon: RiDashboard2Line, icon: dashboardLineNavIcon,
selectedIcon: RiDashboard2Fill, selectedIcon: dashboardFillNavIcon,
}, },
] ]
: []), : []),
@@ -149,8 +170,8 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
{ {
name: t(($) => $['settings.resourceAccess'], { ns: 'common' }), name: t(($) => $['settings.resourceAccess'], { ns: 'common' }),
href: `/app/${appId}/access-config`, href: `/app/${appId}/access-config`,
icon: RiLock2Line, icon: lockLineNavIcon,
selectedIcon: RiLock2Fill, selectedIcon: lockFillNavIcon,
}, },
] ]
: []), : []),
@@ -1,378 +0,0 @@
import type { App, AppSSO } from '@/types/app'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
import AppInfoDetailPanel from '../app-info-detail-panel'
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
value: ['app.create_and_management'] as string[],
}))
const mockConsoleState = vi.hoisted(() => ({
current: {
userProfile: { id: 'user-1' },
get workspacePermissionKeys() {
return mockWorkspacePermissionKeys.value
},
},
}))
const render = (ui: Parameters<typeof renderWithConsoleState>[0]) =>
renderWithConsoleState(ui, {
wrapper: createConsoleQueryWrapper({ accountProfile: { id: 'user-1' } }).wrapper,
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState.current)
})
vi.mock('../../../base/app-icon', () => ({
default: ({ size, icon }: { size: string; icon: string }) => (
<div data-testid="app-icon" data-size={size} data-icon={icon} />
),
}))
vi.mock('../app-info-detail-drawer', () => ({
AppInfoDetailDrawer: ({
open,
onClose,
children,
}: {
open: boolean
onClose: () => void
children: React.ReactNode
}) =>
open ? (
<div data-testid="app-info-detail-drawer">
<button type="button" data-testid="drawer-close" onClick={onClose}>
Close
</button>
{children}
</div>
) : null,
}))
vi.mock('@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view', () => ({
default: ({ appId }: { appId: string }) => <div data-testid="card-view" data-app-id={appId} />,
}))
vi.mock('../app-operations', () => ({
default: ({
primaryOperations,
secondaryOperations,
}: {
primaryOperations?: Array<{
id: string
title: string
onClick: () => void
disabled?: boolean
loading?: boolean
}>
secondaryOperations?: Array<{ id: string; title: string; onClick: () => void; type?: string }>
}) => (
<div data-testid="app-operations">
{primaryOperations?.map((op) => (
<button
key={op.id}
type="button"
data-testid={`op-${op.id}`}
data-loading={op.loading || undefined}
disabled={op.disabled}
onClick={op.onClick}
>
{op.title}
</button>
))}
{secondaryOperations?.map((op) =>
op.type === 'divider' ? (
<button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}>
divider
</button>
) : (
<button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}>
{op.title}
</button>
),
)}
</div>
),
}))
const defaultAppPermissionKeys = [
AppACLPermission.Edit,
AppACLPermission.ImportExportDSL,
AppACLPermission.Delete,
]
const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> =>
({
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
description: 'A test description',
use_icon_as_answer_icon: false,
permission_keys: defaultAppPermissionKeys,
...overrides,
}) as App & Partial<AppSSO>
describe('AppInfoDetailPanel', () => {
const defaultProps = {
appDetail: createAppDetail(),
show: true,
onClose: vi.fn(),
openModal: vi.fn(),
isExporting: false,
exportCheck: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockWorkspacePermissionKeys.value = ['app.create_and_management']
})
describe('Rendering', () => {
it('should not render when show is false', () => {
render(<AppInfoDetailPanel {...defaultProps} show={false} />)
expect(screen.queryByTestId('app-info-detail-drawer')).not.toBeInTheDocument()
})
it('should render drawer when show is true', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('app-info-detail-drawer')).toBeInTheDocument()
})
it('should display app name', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByText('Test App')).toBeInTheDocument()
})
it('should display app mode label', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
})
it('should display description when available', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByText('A test description')).toBeInTheDocument()
})
it('should not display description when empty', () => {
render(
<AppInfoDetailPanel {...defaultProps} appDetail={createAppDetail({ description: '' })} />,
)
expect(screen.queryByText('A test description')).not.toBeInTheDocument()
})
it('should not display description when undefined', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ description: undefined as unknown as string })}
/>,
)
expect(screen.queryByText('A test description')).not.toBeInTheDocument()
})
it('should render CardView with correct appId', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
const cardView = screen.getByTestId('card-view')
expect(cardView).toHaveAttribute('data-app-id', 'app-1')
})
it('should render app icon with large size', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
const icon = screen.getByTestId('app-icon')
expect(icon).toHaveAttribute('data-size', 'large')
})
})
describe('Operations', () => {
it('should render edit, duplicate, and export operations', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('op-edit')).toBeInTheDocument()
expect(screen.getByTestId('op-duplicate')).toBeInTheDocument()
expect(screen.getByTestId('op-export')).toBeInTheDocument()
})
it('should call openModal with edit when edit is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('op-edit'))
expect(defaultProps.openModal).toHaveBeenCalledWith('edit')
})
it('should call openModal with duplicate when duplicate is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('op-duplicate'))
expect(defaultProps.openModal).toHaveBeenCalledWith('duplicate')
})
it('should hide duplicate operation when app.create_and_management permission is missing', () => {
mockWorkspacePermissionKeys.value = []
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.queryByTestId('op-duplicate')).not.toBeInTheDocument()
})
it('should call exportCheck when export is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('op-export'))
expect(defaultProps.exportCheck).toHaveBeenCalledTimes(1)
})
it('should show the export operation as loading while export is pending', () => {
render(<AppInfoDetailPanel {...defaultProps} isExporting />)
expect(screen.getByTestId('op-export')).toHaveAttribute('data-loading', 'true')
expect(screen.getByTestId('op-export')).not.toBeDisabled()
})
it('should render delete operation', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('op-delete')).toBeInTheDocument()
})
it('should call openModal with delete when delete is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('op-delete'))
expect(defaultProps.openModal).toHaveBeenCalledWith('delete')
})
})
describe('Import DSL option', () => {
it('should show import DSL for advanced_chat mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })}
/>,
)
expect(screen.getByTestId('op-import')).toBeInTheDocument()
})
it('should show import DSL for workflow mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.WORKFLOW })}
/>,
)
expect(screen.getByTestId('op-import')).toBeInTheDocument()
})
it('should not show import DSL for chat mode', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.queryByTestId('op-import')).not.toBeInTheDocument()
})
it('should not show import DSL when import/export DSL permission is missing', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({
mode: AppModeEnum.WORKFLOW,
permission_keys: [AppACLPermission.Edit],
})}
/>,
)
expect(screen.queryByTestId('op-import')).not.toBeInTheDocument()
})
it('should call openModal with importDSL when import is clicked', async () => {
const user = userEvent.setup()
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })}
/>,
)
await user.click(screen.getByTestId('op-import'))
expect(defaultProps.openModal).toHaveBeenCalledWith('importDSL')
})
it('should render divider in secondary operations', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
const divider = screen.getByTestId('op-divider-1')
expect(divider).toBeInTheDocument()
await user.click(divider)
})
})
describe('Switch operation', () => {
it('should show switch button for chat mode', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByText('app.switch')).toBeInTheDocument()
})
it('should show switch button for completion mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.COMPLETION })}
/>,
)
expect(screen.getByText('app.switch')).toBeInTheDocument()
})
it('should not show switch button for workflow mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.WORKFLOW })}
/>,
)
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
})
it('should not show switch button for advanced_chat mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })}
/>,
)
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
})
it('should call openModal with switch when switch button is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByText('app.switch'))
expect(defaultProps.openModal).toHaveBeenCalledWith('switch')
})
})
describe('Drawer interactions', () => {
it('should call onClose when drawer close button is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('drawer-close'))
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
})
})
@@ -1,10 +1,35 @@
import type { App, AppSSO } from '@/types/app' import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react' import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import * as React from 'react' import * as React from 'react'
import { createAccountProfileQueryWrapper } from '@/test/console/account-profile'
import { render as renderWithConsoleState } from '@/test/console/render'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
import AppInfoTrigger from '../app-info-trigger' import AppInfoTrigger from '../app-info-trigger'
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
value: ['app.create_and_management'] as string[],
}))
const mockConsoleState = vi.hoisted(() => ({
current: {
userProfile: { id: 'user-1' },
get workspacePermissionKeys() {
return mockWorkspacePermissionKeys.value
},
},
}))
const render = (ui: Parameters<typeof renderWithConsoleState>[0]) =>
renderWithConsoleState(ui, {
wrapper: createAccountProfileQueryWrapper({ id: 'user-1' }),
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState.current)
})
vi.mock('../../../base/app-icon', () => ({ vi.mock('../../../base/app-icon', () => ({
default: ({ default: ({
size, size,
@@ -19,6 +44,12 @@ vi.mock('../../../base/app-icon', () => ({
}) => <div data-testid="app-icon" data-size={size} data-icon={icon} data-bg={background} />, }) => <div data-testid="app-icon" data-size={size} data-icon={icon} data-bg={background} />,
})) }))
const defaultAppPermissionKeys = [
AppACLPermission.Edit,
AppACLPermission.ImportExportDSL,
AppACLPermission.Delete,
]
const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> => const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> =>
({ ({
id: 'app-1', id: 'app-1',
@@ -30,83 +61,121 @@ const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> =>
icon_url: '', icon_url: '',
description: 'A test app', description: 'A test app',
use_icon_as_answer_icon: false, use_icon_as_answer_icon: false,
permission_keys: defaultAppPermissionKeys,
maintainer: 'user-1',
...overrides, ...overrides,
}) as App & Partial<AppSSO> }) as App & Partial<AppSSO>
const createProps = (overrides: Partial<React.ComponentProps<typeof AppInfoTrigger>> = {}) => ({
appDetail: createAppDetail(),
expand: true,
openModal: vi.fn(),
isExporting: false,
exportCheck: vi.fn(),
...overrides,
})
const getOperationsTrigger = () =>
screen.getByRole('button', { name: /common\.operation\.moreActionsFor/ })
describe('AppInfoTrigger', () => { describe('AppInfoTrigger', () => {
it('should render app icon with correct size when expanded', () => { beforeEach(() => {
render(<AppInfoTrigger appDetail={createAppDetail()} expand onClick={vi.fn()} />) vi.clearAllMocks()
const icon = screen.getByTestId('app-icon') mockWorkspacePermissionKeys.value = ['app.create_and_management']
expect(icon).toHaveAttribute('data-size', 'large')
}) })
it('should render app icon with small size when collapsed', () => { it('renders expanded app metadata without making the app info clickable', async () => {
render(<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />)
const icon = screen.getByTestId('app-icon')
expect(icon).toHaveAttribute('data-size', 'medium')
})
it('should show app name when expanded', () => {
render(
<AppInfoTrigger
appDetail={createAppDetail({ name: 'My Chatbot' })}
expand
onClick={vi.fn()}
/>,
)
expect(screen.getByText('My Chatbot')).toBeInTheDocument()
})
it('should not show app name when collapsed', () => {
render(
<AppInfoTrigger
appDetail={createAppDetail({ name: 'My Chatbot' })}
expand={false}
onClick={vi.fn()}
/>,
)
expect(screen.queryByText('My Chatbot')).not.toBeInTheDocument()
})
it('should show app mode label when expanded', () => {
render(
<AppInfoTrigger
appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })}
expand
onClick={vi.fn()}
/>,
)
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
})
it('should not show mode label when collapsed', () => {
render(<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />)
expect(screen.queryByText('app.types.chatbot')).not.toBeInTheDocument()
})
it('should call onClick when button is clicked', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const onClick = vi.fn() const props = createProps({
render(<AppInfoTrigger appDetail={createAppDetail()} expand onClick={onClick} />) appDetail: createAppDetail({ name: 'My Chatbot', mode: AppModeEnum.ADVANCED_CHAT }),
})
render(<AppInfoTrigger {...props} />)
await user.click(screen.getByRole('button')) expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', 'large')
expect(screen.getByText('My Chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('My Chatbot').closest('button')).toBeNull()
expect(onClick).toHaveBeenCalledTimes(1) await user.click(screen.getByTestId('app-icon'))
expect(props.openModal).not.toHaveBeenCalled()
expect(props.exportCheck).not.toHaveBeenCalled()
}) })
it('should show settings icon in expanded and collapsed states', () => { it('renders only the medium app icon when collapsed', () => {
const { container, rerender } = render( render(<AppInfoTrigger {...createProps({ expand: false })} />)
<AppInfoTrigger appDetail={createAppDetail()} expand onClick={vi.fn()} />,
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', 'medium')
expect(screen.queryByText('Test App')).not.toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('shows every available chat app operation and keeps workflow conversion last', async () => {
const user = userEvent.setup()
const props = createProps()
render(<AppInfoTrigger {...props} />)
await user.click(getOperationsTrigger())
expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual([
'app.editApp',
'app.duplicate',
'app.export',
'common.operation.delete',
'app.switch',
])
await user.click(screen.getByRole('menuitem', { name: 'app.switch' }))
expect(props.openModal).toHaveBeenCalledWith('switch')
})
it('shows import DSL for workflow apps without a workflow conversion operation', async () => {
const user = userEvent.setup()
const props = createProps({
appDetail: createAppDetail({ mode: AppModeEnum.WORKFLOW }),
})
render(<AppInfoTrigger {...props} />)
await user.click(getOperationsTrigger())
expect(screen.getByRole('menuitem', { name: 'workflow.common.importDSL' })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: 'app.switch' })).not.toBeInTheDocument()
await user.click(screen.getByRole('menuitem', { name: 'workflow.common.importDSL' }))
expect(props.openModal).toHaveBeenCalledWith('importDSL')
})
it('runs export from the menu and disables it while an export is pending', async () => {
const user = userEvent.setup()
const props = createProps({ isExporting: true })
const { rerender } = render(<AppInfoTrigger {...props} />)
await user.click(getOperationsTrigger())
expect(screen.getByRole('menuitem', { name: 'app.export' })).toHaveAttribute(
'aria-disabled',
'true',
) )
expect(container.querySelector('.i-ri-equalizer-2-line')).toBeInTheDocument()
rerender(<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />) const readyProps = createProps()
expect(container.querySelector('.i-ri-equalizer-2-line')).not.toBeInTheDocument() rerender(<AppInfoTrigger {...readyProps} />)
await user.click(screen.getByRole('menuitem', { name: 'app.export' }))
expect(readyProps.exportCheck).toHaveBeenCalledTimes(1)
}) })
it('should center the icon wrapper when collapsed', () => { it('hides the operations trigger when no operation is permitted', () => {
render(<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />) mockWorkspacePermissionKeys.value = []
const iconWrapper = screen.getByTestId('app-icon').parentElement render(
expect(iconWrapper?.parentElement).toHaveClass('items-center') <AppInfoTrigger
{...createProps({
appDetail: createAppDetail({
maintainer: 'user-2',
permission_keys: [AppACLPermission.ViewLayout],
}),
})}
/>,
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
}) })
}) })
@@ -136,39 +136,12 @@ describe('useAppInfoActions', () => {
it('should return initial state correctly', () => { it('should return initial state correctly', () => {
const { result } = renderHook(() => useAppInfoActions({})) const { result } = renderHook(() => useAppInfoActions({}))
expect(result.current.appDetail).toEqual(mockAppDetail) expect(result.current.appDetail).toEqual(mockAppDetail)
expect(result.current.panelOpen).toBe(false)
expect(result.current.activeModal).toBeNull() expect(result.current.activeModal).toBeNull()
expect(result.current.secretEnvList).toEqual([]) expect(result.current.secretEnvList).toEqual([])
}) })
}) })
describe('Panel management', () => { describe('App-scoped state', () => {
it('should toggle panelOpen', () => {
const { result } = renderHook(() => useAppInfoActions({}))
act(() => {
result.current.setPanelOpen(true)
})
expect(result.current.panelOpen).toBe(true)
})
it('should close panel and call onDetailExpand', () => {
const onDetailExpand = vi.fn()
const { result } = renderHook(() => useAppInfoActions({ onDetailExpand }))
act(() => {
result.current.setPanelOpen(true)
})
act(() => {
result.current.closePanel()
})
expect(result.current.panelOpen).toBe(false)
expect(onDetailExpand).toHaveBeenCalledWith(false)
})
it('should reset app-scoped state when resetKey changes', () => { it('should reset app-scoped state when resetKey changes', () => {
const { result, rerender } = renderHook(({ resetKey }) => useAppInfoActions({ resetKey }), { const { result, rerender } = renderHook(({ resetKey }) => useAppInfoActions({ resetKey }), {
initialProps: { resetKey: 'app-1' }, initialProps: { resetKey: 'app-1' },
@@ -176,34 +149,26 @@ describe('useAppInfoActions', () => {
act(() => { act(() => {
result.current.openModal('delete') result.current.openModal('delete')
result.current.setPanelOpen(true)
}) })
expect(result.current.panelOpen).toBe(true)
expect(result.current.activeModal).toBe('delete') expect(result.current.activeModal).toBe('delete')
rerender({ resetKey: 'app-2' }) rerender({ resetKey: 'app-2' })
expect(result.current.panelOpen).toBe(false)
expect(result.current.activeModal).toBeNull() expect(result.current.activeModal).toBeNull()
expect(result.current.secretEnvList).toEqual([]) expect(result.current.secretEnvList).toEqual([])
}) })
}) })
describe('Modal management', () => { describe('Modal management', () => {
it('should open modal and close panel', () => { it('should open modal', () => {
const { result } = renderHook(() => useAppInfoActions({})) const { result } = renderHook(() => useAppInfoActions({}))
act(() => {
result.current.setPanelOpen(true)
})
act(() => { act(() => {
result.current.openModal('edit') result.current.openModal('edit')
}) })
expect(result.current.activeModal).toBe('edit') expect(result.current.activeModal).toBe('edit')
expect(result.current.panelOpen).toBe(false)
}) })
it('should close modal', () => { it('should close modal', () => {
@@ -1,41 +0,0 @@
import type { ReactNode } from 'react'
import {
Drawer,
DrawerBackdrop,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
type AppInfoDetailDrawerProps = {
open: boolean
onClose: () => void
children: ReactNode
}
export function AppInfoDetailDrawer({ open, onClose, children }: AppInfoDetailDrawerProps) {
return (
<Drawer
open={open}
swipeDirection="left"
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose()
}}
>
<DrawerPortal>
<DrawerBackdrop className="cursor-default bg-app-detail-overlay-bg" />
<DrawerViewport>
<DrawerPopup
aria-label="App info"
className="border-divider-burn bg-app-detail-bg p-0 data-[swipe-direction=left]:top-2 data-[swipe-direction=left]:bottom-2 data-[swipe-direction=left]:left-2 data-[swipe-direction=left]:h-auto data-[swipe-direction=left]:w-113 data-[swipe-direction=left]:max-w-[calc(100vw-1rem)] data-[swipe-direction=left]:rounded-2xl data-[swipe-direction=left]:border-r"
>
<DrawerContent className="flex min-h-0 flex-1 flex-col overflow-hidden p-0 pb-0">
{children}
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}
@@ -1,198 +0,0 @@
import type { Operation } from './app-operations'
import type { AppInfoModalType } from './use-app-info-actions'
import type { App, AppSSO } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import {
RiDeleteBinLine,
RiEditLine,
RiExchange2Line,
RiFileCopy2Line,
RiFileDownloadLine,
RiFileUploadLine,
} from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { AppModeEnum } from '@/types/app'
import { getAppACLCapabilities, hasPermission } from '@/utils/permission'
import AppIcon from '../../base/app-icon'
import { AppInfoDetailDrawer } from './app-info-detail-drawer'
import { getAppModeLabel } from './app-mode-labels'
import AppOperations from './app-operations'
type AppInfoDetailPanelProps = {
appDetail: App & Partial<AppSSO>
show: boolean
onClose: () => void
openModal: (modal: Exclude<AppInfoModalType, null>) => void
isExporting: boolean
exportCheck: () => void
}
const AppInfoDetailPanel = ({
appDetail,
show,
onClose,
openModal,
isExporting,
exportCheck,
}: AppInfoDetailPanelProps) => {
const { t } = useTranslation()
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const appACLCapabilities = useMemo(
() =>
getAppACLCapabilities(appDetail.permission_keys, {
currentUserId,
resourceMaintainer: appDetail.maintainer,
workspacePermissionKeys,
}),
[appDetail.maintainer, appDetail.permission_keys, currentUserId, workspacePermissionKeys],
)
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const primaryOperations = useMemo<Operation[]>(
() => [
...(appACLCapabilities.canEdit
? [
{
id: 'edit',
title: t(($) => $.editApp, { ns: 'app' }),
icon: <RiEditLine />,
onClick: () => openModal('edit'),
},
]
: []),
...(canCreateApp
? [
{
id: 'duplicate',
title: t(($) => $.duplicate, { ns: 'app' }),
icon: <RiFileCopy2Line />,
onClick: () => openModal('duplicate'),
},
]
: []),
...(appACLCapabilities.canImportExportDSL
? [
{
id: 'export',
title: t(($) => $.export, { ns: 'app' }),
icon: <RiFileDownloadLine />,
onClick: exportCheck,
loading: isExporting,
},
]
: []),
],
[appACLCapabilities, canCreateApp, t, openModal, exportCheck, isExporting],
)
const secondaryOperations = useMemo<Operation[]>(
() => [
...(appACLCapabilities.canImportExportDSL &&
(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)
? [
{
id: 'import',
title: t(($) => $['common.importDSL'], { ns: 'workflow' }),
icon: <RiFileUploadLine />,
onClick: () => openModal('importDSL'),
},
]
: []),
...(appACLCapabilities.canDelete
? [
{
id: 'divider-1',
title: '',
icon: <></>,
onClick: () => {},
type: 'divider' as const,
},
{
id: 'delete',
title: t(($) => $['operation.delete'], { ns: 'common' }),
icon: <RiDeleteBinLine />,
onClick: () => openModal('delete'),
},
]
: []),
],
[appACLCapabilities, appDetail.mode, t, openModal],
)
const switchOperation = useMemo(() => {
if (!appACLCapabilities.canEdit) return null
if (appDetail.mode !== AppModeEnum.COMPLETION && appDetail.mode !== AppModeEnum.CHAT)
return null
return {
id: 'switch',
title: t(($) => $.switch, { ns: 'app' }),
icon: <RiExchange2Line />,
onClick: () => openModal('switch'),
}
}, [appACLCapabilities.canEdit, appDetail.mode, t, openModal])
return (
<AppInfoDetailDrawer open={show} onClose={onClose}>
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
<div className="flex items-center gap-3 self-stretch">
<AppIcon
size="large"
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<div className="flex flex-1 flex-col items-start justify-center overflow-hidden">
<h2 className="w-full truncate system-md-semibold text-text-secondary">
{appDetail.name}
</h2>
<div className="system-2xs-medium-uppercase text-text-tertiary">
{getAppModeLabel(appDetail.mode, t)}
</div>
</div>
</div>
{appDetail.description && (
<p className="overflow-wrap-anywhere max-h-26.25 w-full max-w-full overflow-y-auto system-xs-regular wrap-break-word whitespace-normal text-text-tertiary">
{appDetail.description}
</p>
)}
<AppOperations
gap={4}
primaryOperations={primaryOperations}
secondaryOperations={secondaryOperations}
/>
</div>
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button
size="medium"
variant="ghost"
onClick={switchOperation.onClick}
>
{switchOperation.icon}
<span className="system-sm-medium text-text-tertiary">{switchOperation.title}</span>
</Button>
</div>
)}
</AppInfoDetailDrawer>
)
}
export default React.memo(AppInfoDetailPanel)
@@ -12,10 +12,10 @@ import {
AlertDialogDescription, AlertDialogDescription,
AlertDialogTitle, AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog' } from '@langgenius/dify-ui/alert-dialog'
import { Input } from '@langgenius/dify-ui/input'
import * as React from 'react' import * as React from 'react'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { DSLExportConfirmContent } from '@/app/components/workflow/dsl-export-confirm-modal' import { DSLExportConfirmContent } from '@/app/components/workflow/dsl-export-confirm-modal'
import dynamic from '@/next/dynamic' import dynamic from '@/next/dynamic'
@@ -1,63 +1,157 @@
import type { Operation } from './app-operations'
import type { AppInfoModalType } from './use-app-info-actions'
import type { App, AppSSO } from '@/types/app' import type { App, AppSSO } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { AppModeEnum } from '@/types/app'
import { getAppACLCapabilities, hasPermission } from '@/utils/permission'
import AppIcon from '../../base/app-icon' import AppIcon from '../../base/app-icon'
import { getAppModeLabel } from './app-mode-labels' import { getAppModeLabel } from './app-mode-labels'
import AppOperations from './app-operations'
type AppInfoTriggerProps = { type AppInfoTriggerProps = {
appDetail: App & Partial<AppSSO> appDetail: App & Partial<AppSSO>
expand: boolean expand: boolean
onClick: () => void openModal: (modal: Exclude<AppInfoModalType, null>) => void
isExporting: boolean
exportCheck: () => void
} }
const AppInfoTrigger = ({ appDetail, expand, onClick }: AppInfoTriggerProps) => { const AppInfoTrigger = ({
appDetail,
expand,
openModal,
isExporting,
exportCheck,
}: AppInfoTriggerProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const modeLabel = getAppModeLabel(appDetail.mode, t) const modeLabel = getAppModeLabel(appDetail.mode, t)
const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, {
currentUserId,
resourceMaintainer: appDetail.maintainer,
workspacePermissionKeys,
})
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const mainOperations: Operation[] = [
...(appACLCapabilities.canEdit
? [
{
id: 'edit',
title: t(($) => $.editApp, { ns: 'app' }),
icon: 'i-ri-edit-line',
onClick: () => openModal('edit'),
},
]
: []),
...(canCreateApp
? [
{
id: 'duplicate',
title: t(($) => $.duplicate, { ns: 'app' }),
icon: 'i-ri-file-copy-2-line',
onClick: () => openModal('duplicate'),
},
]
: []),
...(appACLCapabilities.canImportExportDSL
? [
{
id: 'export',
title: t(($) => $.export, { ns: 'app' }),
icon: 'i-ri-file-download-line',
onClick: exportCheck,
loading: isExporting,
},
]
: []),
...(appACLCapabilities.canImportExportDSL &&
(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)
? [
{
id: 'import',
title: t(($) => $['common.importDSL'], { ns: 'workflow' }),
icon: 'i-ri-file-upload-line',
onClick: () => openModal('importDSL'),
},
]
: []),
]
const destructiveOperations: Operation[] = appACLCapabilities.canDelete
? [
{
id: 'delete',
title: t(($) => $['operation.delete'], { ns: 'common' }),
icon: 'i-ri-delete-bin-line',
onClick: () => openModal('delete'),
variant: 'destructive',
},
]
: []
const workflowConversionOperations: Operation[] =
appACLCapabilities.canEdit &&
(appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT)
? [
{
id: 'switch',
title: t(($) => $.switch, { ns: 'app' }),
icon: 'i-ri-exchange-2-line',
onClick: () => openModal('switch'),
},
]
: []
return ( return (
<button <div
type="button" className={cn(
onClick={onClick} 'rounded-xl',
className="block w-full" expand ? 'flex items-start gap-2 p-2' : 'flex items-center justify-center px-1 py-1.5',
aria-label={!expand ? `${appDetail.name} - ${modeLabel}` : undefined} )}
> >
<div <div className="flex shrink-0 items-center">
className={cn( <div>
'rounded-xl hover:bg-state-base-hover', <AppIcon
expand ? 'flex items-start gap-2 p-2' : 'flex items-center justify-center px-1 py-1.5', size={expand ? 'large' : 'medium'}
)} iconType={appDetail.icon_type}
> icon={appDetail.icon}
<div className="flex shrink-0 items-center"> background={appDetail.icon_background}
<div> imageUrl={appDetail.icon_url}
<AppIcon />
size={expand ? 'large' : 'medium'} </div>
iconType={appDetail.icon_type} </div>
icon={appDetail.icon} {expand && (
background={appDetail.icon_background} <div className="flex min-w-0 flex-1 flex-col items-start justify-center gap-0.5 self-stretch">
imageUrl={appDetail.icon_url} <div className="flex w-full min-w-0 items-center gap-2 pr-1">
<div className="min-w-0 flex-1 truncate system-md-semibold text-text-secondary">
{appDetail.name}
</div>
<AppOperations
appName={appDetail.name}
operationGroups={[
mainOperations,
destructiveOperations,
workflowConversionOperations,
]}
/> />
</div> </div>
<div className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary">
{modeLabel}
</div>
</div> </div>
{expand && ( )}
<> </div>
<div className="flex min-w-0 flex-1 flex-col items-start justify-center gap-0.5 self-stretch">
<div className="flex w-full min-w-0 pr-1">
<div className="truncate system-md-semibold text-text-secondary">
{appDetail.name}
</div>
</div>
<div className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary">
{modeLabel}
</div>
</div>
<div className="flex size-5 shrink-0 items-center justify-center rounded-md p-0.5">
<span aria-hidden className="i-ri-equalizer-2-line size-4 text-text-tertiary" />
</div>
</>
)}
</div>
</button>
) )
} }
@@ -1,218 +1,92 @@
import type { JSX } from 'react' import { cn } from '@langgenius/dify-ui/cn'
import { Button } from '@langgenius/dify-ui/button'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu' } from '@langgenius/dify-ui/dropdown-menu'
import { RiMoreLine } from '@remixicon/react' import { Fragment } from 'react'
import { cloneElement, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export type Operation = { export type Operation = {
id: string id: string
title: string title: string
icon: JSX.Element icon: string
onClick: () => void onClick: () => void
disabled?: boolean disabled?: boolean
loading?: boolean loading?: boolean
type?: 'divider' variant?: 'default' | 'destructive'
} }
type AppOperationsProps = { type AppOperationsProps = {
gap: number appName: string
operations?: Operation[] operationGroups: Operation[][]
primaryOperations?: Operation[]
secondaryOperations?: Operation[]
} }
const EMPTY_OPERATIONS: Operation[] = [] const AppOperations = ({ appName, operationGroups }: AppOperationsProps) => {
const AppOperations = ({
operations,
primaryOperations,
secondaryOperations,
gap,
}: AppOperationsProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [visibleOpreations, setVisibleOperations] = useState<Operation[]>([]) const visibleGroups = operationGroups.filter((group) => group.length > 0)
const [moreOperations, setMoreOperations] = useState<Operation[]>([])
const [showMore, setShowMore] = useState(false)
const navRef = useRef<HTMLDivElement>(null)
const primaryOps = useMemo(() => { if (!visibleGroups.length) return null
if (operations) return operations
if (primaryOperations) return primaryOperations
return EMPTY_OPERATIONS
}, [operations, primaryOperations])
const secondaryOps = useMemo(() => {
if (operations) return EMPTY_OPERATIONS
if (secondaryOperations) return secondaryOperations
return EMPTY_OPERATIONS
}, [operations, secondaryOperations])
const inlineOperations = primaryOps.filter((operation) => operation.type !== 'divider')
useEffect(() => {
const applyState = (visible: Operation[], overflow: Operation[]) => {
const combinedMore = [...overflow, ...secondaryOps]
if (!overflow.length && combinedMore[0]?.type === 'divider') combinedMore.shift()
setVisibleOperations(visible)
setMoreOperations(combinedMore)
}
const inline = primaryOps.filter((operation) => operation.type !== 'divider')
if (!inline.length) {
applyState([], [])
return
}
const navElement = navRef.current
const moreElement = document.getElementById('more-measure')
if (!navElement || !moreElement) return
let width = 0
const containerWidth = navElement.clientWidth
const moreWidth = moreElement.clientWidth
if (containerWidth === 0 || moreWidth === 0) return
const updatedEntries: Record<string, boolean> = inline.reduce(
(pre, cur) => {
pre[cur.id] = false
return pre
},
{} as Record<string, boolean>,
)
const childrens = Array.from(navElement.children).slice(0, -1)
for (let i = 0; i < childrens.length; i++) {
const child = childrens[i] as HTMLElement
const id = child.dataset.targetid
if (!id) break
const childWidth = child.clientWidth
if (width + gap + childWidth + moreWidth <= containerWidth) {
updatedEntries[id] = true
width += gap + childWidth
} else {
if (i === childrens.length - 1 && width + childWidth <= containerWidth)
updatedEntries[id] = true
else updatedEntries[id] = false
break
}
}
const visible = inline.filter((item) => updatedEntries[item.id])
const overflow = inline.filter((item) => !updatedEntries[item.id])
applyState(visible, overflow)
}, [gap, primaryOps, secondaryOps])
const shouldShowMoreButton = moreOperations.length > 0
return ( return (
<> <DropdownMenu modal={false}>
<div <DropdownMenuTrigger
aria-hidden="true" aria-label={t(($) => $['operation.moreActionsFor'], {
ref={navRef} ns: 'common',
className="pointer-events-none flex h-0 items-center self-stretch overflow-hidden" name: appName,
style={{ gap }} })}
className="flex size-5 shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
> >
{inlineOperations.map((operation) => ( <span aria-hidden className="i-ri-more-fill size-4" />
<Button </DropdownMenuTrigger>
key={operation.id} <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-40">
data-targetid={operation.id} {visibleGroups.map((group, groupIndex) => (
size="small" <Fragment key={group.map((operation) => operation.id).join('-')}>
variant="secondary" {groupIndex > 0 && <DropdownMenuSeparator />}
className="focus-visible:ring-inset" <DropdownMenuGroup>
disabled={operation.disabled} {group.map((operation) => (
loading={operation.loading} <DropdownMenuItem
tabIndex={-1} key={operation.id}
> variant={operation.variant}
{cloneElement(operation.icon, { className="gap-2 px-3"
className: 'h-3.5 w-3.5 text-components-button-secondary-text', disabled={operation.disabled || operation.loading}
})} onClick={operation.onClick}
<span className="system-xs-medium text-components-button-secondary-text"> >
{operation.title} {operation.loading ? (
</span> <span
</Button> aria-hidden
))} className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none"
<Button />
id="more-measure" ) : (
size="small" <span
variant="secondary" aria-hidden
className="focus-visible:ring-inset" className={cn(
tabIndex={-1} operation.icon,
> 'size-4',
<RiMoreLine className="size-3.5 text-components-button-secondary-text" /> operation.variant === 'destructive'
<span className="system-xs-medium text-components-button-secondary-text"> ? 'text-text-destructive'
{t(($) => $['operation.more'], { ns: 'common' })} : 'text-text-tertiary',
</span> )}
</Button> />
</div> )}
<div className="flex items-center self-stretch overflow-hidden" style={{ gap }}> <span
{visibleOpreations.map((operation) => ( className={cn(
<Button 'system-sm-regular',
key={operation.id} operation.variant !== 'destructive' && 'text-text-secondary',
data-targetid={operation.id} )}
size="small"
variant="secondary"
className="focus-visible:ring-inset"
disabled={operation.disabled}
loading={operation.loading}
onClick={operation.onClick}
>
{cloneElement(operation.icon, {
className: 'h-3.5 w-3.5 text-components-button-secondary-text',
})}
<span className="system-xs-medium text-components-button-secondary-text">
{operation.title}
</span>
</Button>
))}
{shouldShowMoreButton && (
<DropdownMenu open={showMore} onOpenChange={setShowMore}>
<DropdownMenuTrigger
render={
<Button size="small" variant="secondary" className="focus-visible:ring-inset" />
}
>
<>
<RiMoreLine className="size-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium text-components-button-secondary-text">
{t(($) => $['operation.more'], { ns: 'common' })}
</span>
</>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[264px]"
>
{moreOperations.map((item) =>
item.type === 'divider' ? (
<DropdownMenuSeparator key={item.id} />
) : (
<DropdownMenuItem
key={item.id}
className="gap-x-1 px-1.5"
disabled={item.disabled}
onClick={item.onClick}
> >
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} {operation.title}
<span className="system-md-regular text-text-secondary">{item.title}</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
), ))}
)} </DropdownMenuGroup>
</DropdownMenuContent> </Fragment>
</DropdownMenu> ))}
)} </DropdownMenuContent>
</div> </DropdownMenu>
</>
) )
} }
@@ -1,35 +1,15 @@
import type { AppInfoActions } from './use-app-info-actions' import type { AppInfoActions } from './use-app-info-actions'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { getAppACLCapabilities } from '@/utils/permission'
import AppInfoDetailPanel from './app-info-detail-panel'
import AppInfoModals from './app-info-modals' import AppInfoModals from './app-info-modals'
import AppInfoTrigger from './app-info-trigger' import AppInfoTrigger from './app-info-trigger'
type IAppInfoProps = { type AppInfoViewProps = {
expand: boolean expand: boolean
onlyShowDetail?: boolean
openState?: boolean
onDetailExpand?: (expand: boolean) => void
}
type AppInfoViewProps = Omit<IAppInfoProps, 'onDetailExpand'> & {
actions: AppInfoActions actions: AppInfoActions
renderDetail?: boolean
} }
type AppInfoDetailLayerProps = { export const AppInfoView = ({ expand, actions }: AppInfoViewProps) => {
actions: AppInfoActions
open?: boolean
}
const AppInfoDetailLayer = ({ actions, open = actions.panelOpen }: AppInfoDetailLayerProps) => {
const { const {
appDetail, appDetail,
closePanel,
activeModal, activeModal,
openModal, openModal,
closeModal, closeModal,
@@ -48,10 +28,9 @@ const AppInfoDetailLayer = ({ actions, open = actions.panelOpen }: AppInfoDetail
return ( return (
<> <>
<AppInfoDetailPanel <AppInfoTrigger
appDetail={appDetail} appDetail={appDetail}
show={open} expand={expand}
onClose={closePanel}
openModal={openModal} openModal={openModal}
isExporting={isExporting} isExporting={isExporting}
exportCheck={exportCheck} exportCheck={exportCheck}
@@ -73,44 +52,3 @@ const AppInfoDetailLayer = ({ actions, open = actions.panelOpen }: AppInfoDetail
</> </>
) )
} }
export const AppInfoView = ({
expand,
onlyShowDetail = false,
openState = false,
actions,
renderDetail = true,
}: AppInfoViewProps) => {
const { appDetail, panelOpen, setPanelOpen, activeModal, secretEnvList } = actions
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const appACLCapabilities = getAppACLCapabilities(appDetail?.permission_keys, {
currentUserId,
resourceMaintainer: appDetail?.maintainer,
workspacePermissionKeys,
})
if (!appDetail) return null
const detailLayerOpen = onlyShowDetail ? openState : panelOpen
const shouldRenderDetailLayer =
renderDetail && (detailLayerOpen || activeModal || secretEnvList.length > 0)
return (
<div>
{!onlyShowDetail && (
<AppInfoTrigger
appDetail={appDetail}
expand={expand}
onClick={() => {
if (appACLCapabilities.canAccessLayout) setPanelOpen((v) => !v)
}}
/>
)}
{shouldRenderDetailLayer && <AppInfoDetailLayer actions={actions} open={detailLayerOpen} />}
</div>
)
}
@@ -30,13 +30,11 @@ export type AppInfoModalType =
| null | null
type UseAppInfoActionsParams = { type UseAppInfoActionsParams = {
onDetailExpand?: (expand: boolean) => void
resetKey?: string resetKey?: string
} }
type AppInfoUiState = { type AppInfoUiState = {
resetKey?: string resetKey?: string
panelOpen: boolean
activeModal: AppInfoModalType activeModal: AppInfoModalType
secretEnvList: EnvironmentVariableItemResponse[] secretEnvList: EnvironmentVariableItemResponse[]
} }
@@ -75,7 +73,6 @@ const updateCachedAppMetadata = (cachedApp: AppDetailWithSite | undefined, app:
const createInitialUiState = (resetKey?: string): AppInfoUiState => ({ const createInitialUiState = (resetKey?: string): AppInfoUiState => ({
resetKey, resetKey,
panelOpen: false,
activeModal: null, activeModal: null,
secretEnvList: [], secretEnvList: [],
}) })
@@ -88,7 +85,7 @@ const getCurrentUiState = (state: AppInfoUiState, resetKey?: string) => {
return state.resetKey === resetKey ? state : createInitialUiState(resetKey) return state.resetKey === resetKey ? state : createInitialUiState(resetKey)
} }
export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoActionsParams) { export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) {
const { t } = useTranslation() const { t } = useTranslation()
const { replace } = useRouter() const { replace } = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -103,23 +100,9 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction
const [uiState, setUiState] = useState(() => createInitialUiState(resetKey)) const [uiState, setUiState] = useState(() => createInitialUiState(resetKey))
const uiStateMatchesResetKey = uiState.resetKey === resetKey const uiStateMatchesResetKey = uiState.resetKey === resetKey
const panelOpen = uiStateMatchesResetKey ? uiState.panelOpen : false
const activeModal = uiStateMatchesResetKey ? uiState.activeModal : null const activeModal = uiStateMatchesResetKey ? uiState.activeModal : null
const secretEnvList = uiStateMatchesResetKey ? uiState.secretEnvList : emptySecretEnvList const secretEnvList = uiStateMatchesResetKey ? uiState.secretEnvList : emptySecretEnvList
const setPanelOpen = useCallback<Dispatch<SetStateAction<boolean>>>(
(value) => {
setUiState((state) => {
const current = getCurrentUiState(state, resetKey)
return {
...current,
panelOpen: resolveStateAction(value, current.panelOpen),
}
})
},
[resetKey],
)
const setActiveModal = useCallback<Dispatch<SetStateAction<AppInfoModalType>>>( const setActiveModal = useCallback<Dispatch<SetStateAction<AppInfoModalType>>>(
(value) => { (value) => {
setUiState((state) => { setUiState((state) => {
@@ -146,17 +129,11 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction
[resetKey], [resetKey],
) )
const closePanel = useCallback(() => {
setPanelOpen(false)
onDetailExpand?.(false)
}, [onDetailExpand, setPanelOpen])
const openModal = useCallback( const openModal = useCallback(
(modal: Exclude<AppInfoModalType, null>) => { (modal: Exclude<AppInfoModalType, null>) => {
closePanel()
setActiveModal(modal) setActiveModal(modal)
}, },
[closePanel, setActiveModal], [setActiveModal],
) )
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
@@ -352,9 +329,6 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction
return { return {
appDetail, appDetail,
panelOpen,
setPanelOpen,
closePanel,
activeModal, activeModal,
openModal, openModal,
closeModal, closeModal,
-127
View File
@@ -1,127 +0,0 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate, WindowCursor } from '@/app/components/base/icons/src/vender/workflow'
import { Infotip } from '@/app/components/base/infotip'
import AppIcon from '../base/app-icon'
type IAppBasicProps = {
iconType?: 'app' | 'api' | 'dataset' | 'webapp' | 'notion'
icon?: string
icon_background?: string | null
isExternal?: boolean
name: string
type: string | React.ReactNode
hoverTip?: string
textStyle?: { main?: string; extra?: string }
isExtraInLine?: boolean
mode?: string
hideType?: boolean
}
const DatasetSvg = (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.833497 5.13481C0.833483 4.69553 0.83347 4.31654 0.858973 4.0044C0.88589 3.67495 0.94532 3.34727 1.10598 3.03195C1.34567 2.56155 1.72812 2.17909 2.19852 1.93941C2.51384 1.77875 2.84152 1.71932 3.17097 1.6924C3.48312 1.6669 3.86209 1.66691 4.30137 1.66693L7.62238 1.66684C8.11701 1.66618 8.55199 1.66561 8.95195 1.80356C9.30227 1.92439 9.62134 2.12159 9.88607 2.38088C10.1883 2.67692 10.3823 3.06624 10.603 3.50894L11.3484 5.00008H14.3679C15.0387 5.00007 15.5924 5.00006 16.0434 5.03691C16.5118 5.07518 16.9424 5.15732 17.3468 5.36339C17.974 5.68297 18.4839 6.19291 18.8035 6.82011C19.0096 7.22456 19.0917 7.65515 19.13 8.12356C19.1668 8.57455 19.1668 9.12818 19.1668 9.79898V13.5345C19.1668 14.2053 19.1668 14.7589 19.13 15.2099C19.0917 15.6784 19.0096 16.1089 18.8035 16.5134C18.4839 17.1406 17.974 17.6505 17.3468 17.9701C16.9424 18.1762 16.5118 18.2583 16.0434 18.2966C15.5924 18.3334 15.0387 18.3334 14.3679 18.3334H5.63243C4.96163 18.3334 4.40797 18.3334 3.95698 18.2966C3.48856 18.2583 3.05798 18.1762 2.65353 17.9701C2.02632 17.6505 1.51639 17.1406 1.19681 16.5134C0.990734 16.1089 0.908597 15.6784 0.870326 15.2099C0.833478 14.7589 0.833487 14.2053 0.833497 13.5345V5.13481ZM7.51874 3.33359C8.17742 3.33359 8.30798 3.34447 8.4085 3.37914C8.52527 3.41942 8.63163 3.48515 8.71987 3.57158C8.79584 3.64598 8.86396 3.7579 9.15852 4.34704L9.48505 5.00008L2.50023 5.00008C2.50059 4.61259 2.50314 4.34771 2.5201 4.14012C2.5386 3.91374 2.57 3.82981 2.59099 3.7886C2.67089 3.6318 2.79837 3.50432 2.95517 3.42442C2.99638 3.40343 3.08031 3.37203 3.30669 3.35353C3.54281 3.33424 3.85304 3.33359 4.3335 3.33359H7.51874Z"
fill="#444CE7"
/>
</svg>
)
const NotionSvg = (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_6294_13848)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.287 21.9133L1.70748 18.6999C1.08685 17.9267 0.75 16.976 0.75 15.9974V4.36124C0.75 2.89548 1.92269 1.67923 3.43553 1.57594L15.3991 0.759137C16.2682 0.699797 17.1321 0.930818 17.8461 1.41353L22.0494 4.25543C22.8018 4.76414 23.25 5.59574 23.25 6.48319V19.7124C23.25 21.1468 22.0969 22.3345 20.6157 22.4256L7.3375 23.243C6.1555 23.3158 5.01299 22.8178 4.287 21.9133Z"
fill="white"
/>
<path
d="M8.43607 10.1842V10.0318C8.43607 9.64564 8.74535 9.32537 9.14397 9.29876L12.0475 9.10491L16.0628 15.0178V9.82823L15.0293 9.69046V9.6181C15.0293 9.22739 15.3456 8.90501 15.7493 8.88433L18.3912 8.74899V9.12918C18.3912 9.30765 18.2585 9.46031 18.0766 9.49108L17.4408 9.59861V18.0029L16.6429 18.2773C15.9764 18.5065 15.2343 18.2611 14.8527 17.6853L10.9545 11.803V17.4173L12.1544 17.647L12.1377 17.7583C12.0853 18.1069 11.7843 18.3705 11.4202 18.3867L8.43607 18.5195C8.39662 18.1447 8.67758 17.8093 9.06518 17.7686L9.45771 17.7273V10.2416L8.43607 10.1842Z"
fill="black"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.5062 2.22521L3.5426 3.04201C2.82599 3.09094 2.27051 3.66706 2.27051 4.36136V15.9975C2.27051 16.6499 2.49507 17.2837 2.90883 17.7992L5.48835 21.0126C5.90541 21.5322 6.56174 21.8183 7.24076 21.7765L20.519 20.9591C21.1995 20.9172 21.7293 20.3716 21.7293 19.7125V6.48332C21.7293 6.07557 21.5234 5.69348 21.1777 5.45975L16.9743 2.61784C16.546 2.32822 16.0277 2.1896 15.5062 2.22521ZM4.13585 4.54287C3.96946 4.41968 4.04865 4.16303 4.25768 4.14804L15.5866 3.33545C15.9476 3.30956 16.3063 3.40896 16.5982 3.61578L18.8713 5.22622C18.9576 5.28736 18.9171 5.41935 18.8102 5.42516L6.8129 6.07764C6.44983 6.09739 6.09144 5.99073 5.80276 5.77699L4.13585 4.54287ZM6.25018 8.12315C6.25018 7.7334 6.56506 7.41145 6.9677 7.38952L19.6523 6.69871C20.0447 6.67734 20.375 6.97912 20.375 7.35898V18.8141C20.375 19.2031 20.0613 19.5247 19.6594 19.5476L7.05516 20.2648C6.61845 20.2896 6.25018 19.954 6.25018 19.5312V8.12315Z"
fill="black"
/>
</g>
<defs>
<clipPath id="clip0_6294_13848">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
)
const ICON_MAP = {
app: <AppIcon className="border border-[rgba(0,0,0,0.05)]!" />,
api: (
<div className="rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md">
<ApiAggregate className="size-4 text-text-primary-on-surface" />
</div>
),
dataset: (
<AppIcon innerIcon={DatasetSvg} className="border-[0.5px]! border-indigo-100! bg-indigo-25!" />
),
webapp: (
<div className="rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md">
<WindowCursor className="size-4 text-text-primary-on-surface" />
</div>
),
notion: (
<AppIcon innerIcon={NotionSvg} className="border-[0.5px]! border-indigo-100! bg-white!" />
),
}
export default function AppBasic({
icon,
icon_background,
name,
isExternal,
type,
hoverTip,
textStyle,
isExtraInLine,
mode = 'expand',
iconType = 'app',
hideType,
}: IAppBasicProps) {
const { t } = useTranslation()
return (
<div className="flex grow items-center">
{icon && icon_background && iconType === 'app' && (
<div className="mr-2 shrink-0">
<AppIcon icon={icon} background={icon_background} />
</div>
)}
{iconType !== 'app' && <div className="mr-2 shrink-0">{ICON_MAP[iconType]}</div>}
{mode === 'expand' && (
<div className="group w-full">
<div
className={`flex flex-row items-center system-md-semibold text-text-secondary group-hover:text-text-primary ${textStyle?.main ?? ''}`}
>
<div className="min-w-0 overflow-hidden break-normal text-ellipsis">{name}</div>
{hoverTip && (
<Infotip aria-label={hoverTip} className="ml-1" popupClassName="w-[240px]">
{hoverTip}
</Infotip>
)}
</div>
{!hideType && isExtraInLine && (
<div className="flex system-2xs-medium-uppercase text-text-tertiary">{type}</div>
)}
{!hideType && !isExtraInLine && (
<div className="system-2xs-medium-uppercase text-text-tertiary">
{isExternal ? t(($) => $.externalTag, { ns: 'dataset' }) : type}
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,49 @@
import type { AccessPointStatus } from '../shared/access-point-status'
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import { AccessPointCard } from '../shared/access-point-card'
describe('AccessPointCard', () => {
it('marks the card when it is the highlighted access point', () => {
render(
<AccessPointCard
title="Web App"
description="Web application access"
icon="i-ri-robot-2-line"
status="inService"
highlighted
>
Access URL
</AccessPointCard>,
)
expect(screen.getByRole('region', { name: 'Web App' })).toHaveAttribute(
'data-highlighted',
'true',
)
})
it.each<[AccessPointStatus, string, boolean]>([
['loading', 'common.loading', true],
['unsupported', 'deployments.studio.accessPoint.notSupported', false],
['unavailable', 'deployments.health.ENVIRONMENT_STATUS_FAILED', false],
])('renders the %s state independently', (status, label, busy) => {
render(
<AccessPointCard
title="Web App"
description="Web application access"
icon="i-ri-robot-2-line"
status={status}
onEnabledChange={vi.fn()}
>
Access URL
</AccessPointCard>,
)
expect(screen.getByText(label)).toBeInTheDocument()
const card = screen.getByRole('region', { name: 'Web App' })
if (busy) expect(card).toHaveAttribute('aria-busy', 'true')
else expect(card).not.toHaveAttribute('aria-busy')
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,33 @@
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import { AccessPointUrl } from '../shared/access-point-url'
const endpointProps = {
label: 'Access URL',
unavailableLabel: 'FAILED',
value: 'https://example.test/access',
}
describe('AccessPointUrl', () => {
it('keeps a disabled endpoint visible without marking it unavailable', () => {
render(<AccessPointUrl {...endpointProps} enabled={false} showOpen openLabel="Open" />)
expect(screen.getByText(endpointProps.value)).toBeInTheDocument()
expect(screen.queryByText(endpointProps.unavailableLabel)).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Open' })).toBeDisabled()
})
it('shows an unavailable endpoint without replacing it with a loading skeleton', () => {
render(<AccessPointUrl {...endpointProps} enabled={false} unavailable />)
expect(screen.getByText(endpointProps.unavailableLabel)).toBeInTheDocument()
expect(screen.getByText(endpointProps.value)).toBeInTheDocument()
})
it('shows loading independently from the unavailable state', () => {
render(<AccessPointUrl {...endpointProps} enabled={false} loading />)
expect(screen.queryByText(endpointProps.unavailableLabel)).not.toBeInTheDocument()
expect(screen.queryByText(endpointProps.value)).not.toBeInTheDocument()
})
})
@@ -0,0 +1,98 @@
import type { ApiKeyList } from '@dify/contracts/api/console/apps/types.gen'
import type { ReactElement } from 'react'
import type { SecretKeyScope } from '@/app/components/develop/secret-key/secret-key-modal'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { consoleQuery } from '@/service/client'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { ApiSecretKeyButton } from '../shared/api-secret-key-button'
const appApiKeys: ApiKeyList = {
data: [
{ id: 'key-1', token: 'app-a', type: 'app', created_at: 1, last_used_at: 1 },
{ id: 'key-2', token: 'app-b', type: 'app', created_at: 2, last_used_at: 2 },
],
}
const render = (ui: ReactElement) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(
consoleQuery.apps.byResourceId.apiKeys.get.queryKey({
input: { params: { resource_id: 'app-1' } },
}),
appApiKeys,
)
return renderWithConsoleQuery(ui, { queryClient })
}
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
default: ({
canManage,
isShow,
scope,
}: {
canManage: boolean
isShow: boolean
scope: SecretKeyScope
}) =>
isShow ? (
<div role="dialog" aria-label="API key management">
{scope.type === 'dataset' ? '' : scope.appId}:
{scope.type === 'environment' ? scope.environmentId : ''}:{String(canManage)}
</div>
) : null,
}))
describe('ApiSecretKeyButton', () => {
it('shows the current API key count and opens key management', async () => {
const user = userEvent.setup()
render(<ApiSecretKeyButton appId="app-1" canManage />)
const button = screen.getByRole('button', {
name: 'appApi.apiKeyModal.apiSecretKey 2',
})
expect(button).toBeEnabled()
await user.click(button)
expect(screen.getByRole('dialog', { name: 'API key management' })).toHaveTextContent(
'app-1::true',
)
})
it('uses the environment API key count and opens environment-scoped key management', async () => {
const user = userEvent.setup()
render(<ApiSecretKeyButton appId="app-1" environmentId="staging" apiKeyCount={5} canManage />)
const button = screen.getByRole('button', {
name: 'appApi.apiKeyModal.apiSecretKey 5',
})
expect(button).toBeEnabled()
await user.click(button)
expect(screen.getByRole('dialog', { name: 'API key management' })).toHaveTextContent(
'app-1:staging:true',
)
})
it('keeps the current count visible when service access is disabled', () => {
render(<ApiSecretKeyButton appId="app-1" canManage disabled />)
expect(
screen.getByRole('button', {
name: 'appApi.apiKeyModal.apiSecretKey 2',
}),
).toBeDisabled()
})
it('keeps the current count visible without management permission', () => {
render(<ApiSecretKeyButton appId="app-1" canManage={false} />)
expect(
screen.getByRole('button', {
name: 'appApi.apiKeyModal.apiSecretKey 2',
}),
).toBeDisabled()
})
})
@@ -0,0 +1,221 @@
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import { BuiltInAccessPoints } from '../built-in-access-points'
const mocks = vi.hoisted(() => ({
appInfo: {
id: 'app-1',
mode: 'workflow',
enable_site: false,
enable_api: false,
permission_keys: [],
} as Record<string, unknown>,
workflow: {
data: null as Record<string, unknown> | null,
isPending: false,
},
webCard: vi.fn(),
apiCard: vi.fn(),
mcpCard: vi.fn(),
triggerCard: vi.fn(),
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock()
})
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useSuspenseQuery: () => ({
data: {
webapp_auth: { enabled: true },
},
}),
}
})
vi.mock('jotai', async (importOriginal) => {
const actual = await importOriginal<typeof import('jotai')>()
return {
...actual,
useAtomValue: () => undefined,
}
})
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ appDetail: mocks.appInfo }),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => path,
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => mocks.workflow,
}))
vi.mock('@/utils/permission', () => ({
getAppACLCapabilities: () => ({
canEdit: false,
canDeploy: true,
canReleaseAndVersion: false,
}),
}))
vi.mock('../shared/use-access-point-actions', () => ({
useAccessPointActions: () => ({
changeApiStatus: vi.fn(),
changeSiteStatus: vi.fn(),
handleResult: vi.fn(),
refreshAppDetail: vi.fn(),
regenerateSiteCode: vi.fn(),
saveSiteConfig: vi.fn(),
}),
}))
vi.mock('../built-in-access-points/web-app-card', () => ({
WebAppAccessPointCard: (props: Record<string, unknown>) => {
mocks.webCard(props)
return <div data-testid="web-app-card" />
},
}))
vi.mock('../built-in-access-points/service-api-card', () => ({
ServiceApiAccessPointCard: (props: Record<string, unknown>) => {
mocks.apiCard(props)
return <div data-testid="service-api-card" />
},
}))
vi.mock('../built-in-access-points/mcp-card', () => ({
MCPAccessPointCard: (props: Record<string, unknown>) => {
mocks.mcpCard(props)
return <div data-testid="mcp-card" />
},
}))
vi.mock('../built-in-access-points/trigger-card', () => ({
TriggerAccessPointCard: (props: Record<string, unknown>) => {
mocks.triggerCard(props)
return <div data-testid="trigger-card" />
},
}))
describe('BuiltInAccessPoints', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.appInfo = {
id: 'app-1',
mode: 'workflow',
enable_site: false,
enable_api: false,
permission_keys: [],
}
mocks.workflow = {
data: null,
isPending: false,
}
})
it('renders the unpublished state across all access point cards', () => {
render(<BuiltInAccessPoints appId="app-1" />)
expect(screen.getByText('deployments.studio.accessPoint.noPublishedTitle')).toBeInTheDocument()
expect(screen.getByTestId('web-app-card')).toBeInTheDocument()
expect(screen.getByTestId('service-api-card')).toBeInTheDocument()
expect(screen.getByTestId('mcp-card')).toBeInTheDocument()
expect(screen.getByTestId('trigger-card')).toBeInTheDocument()
expect(mocks.webCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canDeploy: true, canEdit: false }),
)
expect(mocks.apiCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canEdit: false }),
)
expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canEdit: false }),
)
})
it('keeps Trigger unavailable when no trigger node is published', () => {
mocks.workflow = {
data: {
graph: {
nodes: [{ data: { type: 'start' } }],
},
},
isPending: false,
}
render(<BuiltInAccessPoints appId="app-1" />)
expect(
screen.queryByText('deployments.studio.accessPoint.noPublishedTitle'),
).not.toBeInTheDocument()
expect(mocks.webCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'available', workflow: mocks.workflow.data }),
)
expect(mocks.apiCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'available' }),
)
expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable' }),
)
})
it('highlights only the targeted built-in access point card', () => {
render(<BuiltInAccessPoints appId="app-1" highlightedAccessPoint="mcp" />)
expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false }))
expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false }))
expect(mocks.mcpCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: true }))
expect(mocks.triggerCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false }))
})
it('enables Trigger and disables the other access points in trigger mode', () => {
mocks.workflow = {
data: {
graph: {
nodes: [{ data: { type: 'trigger-webhook' } }],
},
},
isPending: false,
}
render(<BuiltInAccessPoints appId="app-1" />)
expect(mocks.webCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable' }),
)
expect(mocks.apiCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable' }),
)
expect(mocks.mcpCard).toHaveBeenCalledWith(
expect.objectContaining({ triggerModeDisabled: true }),
)
expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'available' }),
)
expect(
screen.getByText('deployments.studio.accessPoint.triggerExclusiveNotice'),
).toBeInTheDocument()
})
it('keeps all cards visible while the published workflow is loading', () => {
mocks.workflow = {
data: null,
isPending: true,
}
render(<BuiltInAccessPoints appId="app-1" />)
expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' }))
expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' }))
expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'loading' }),
)
})
})
@@ -0,0 +1,79 @@
import type { AccessPoint } from '@/app/components/app/deploy/access-point'
import { screen, within } from '@testing-library/react'
import { render } from '@/test/console/render'
import { DeployedEnvironmentAccessPoints } from '../deployed-environment-access-points'
const mocks = vi.hoisted(() => ({
serviceApiCard: vi.fn(),
webAppCard: vi.fn(),
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock()
})
vi.mock('../deployed-environment-access-points/environment-service-api-card', () => ({
EnvironmentServiceApiCard: (props: Record<string, unknown>) => {
mocks.serviceApiCard(props)
return <div data-testid="environment-service-api-card" />
},
}))
vi.mock('../deployed-environment-access-points/environment-web-app-card', () => ({
EnvironmentWebAppCard: (props: Record<string, unknown>) => {
mocks.webAppCard(props)
return <div data-testid="environment-web-app-card" />
},
}))
describe('DeployedEnvironmentAccessPoints', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each<AccessPoint>(['webApp', 'serviceApi'])(
'highlights only the targeted %s card',
(highlightedAccessPoint) => {
render(
<DeployedEnvironmentAccessPoints
appId="app-1"
environmentId="staging"
canEdit
canManage
highlightedAccessPoint={highlightedAccessPoint}
/>,
)
expect(mocks.webAppCard).toHaveBeenCalledWith(
expect.objectContaining({ highlighted: highlightedAccessPoint === 'webApp' }),
)
expect(mocks.serviceApiCard).toHaveBeenCalledWith(
expect.objectContaining({ highlighted: highlightedAccessPoint === 'serviceApi' }),
)
},
)
it('renders MCP and Trigger as unsupported without a permanent loading state', () => {
render(
<DeployedEnvironmentAccessPoints appId="app-1" environmentId="staging" canEdit canManage />,
)
const mcpCard = screen.getByRole('region', { name: /mcp\.server\.title/ })
const triggerCard = screen.getByRole('region', { name: /settings\.trigger/ })
for (const card of [mcpCard, triggerCard]) {
expect(
within(card).getByText('deployments.studio.accessPoint.notSupported'),
).toBeInTheDocument()
expect(
within(card).getByText('deployments.studio.accessPoint.unsupportedInDeployedEnvironment'),
).toBeInTheDocument()
expect(
within(card).queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
expect(card).not.toHaveAttribute('aria-busy')
expect(card.querySelector('[aria-busy="true"]')).not.toBeInTheDocument()
}
})
})
@@ -0,0 +1,205 @@
import type { ReactElement } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AccessMode } from '@/models/access-control'
import { render } from '@/test/console/render'
import { createTestQueryClient } from '@/test/query-client'
import { EnvironmentAccessControl } from '../deployed-environment-access-points/environment-access-control'
const mocks = vi.hoisted(() => ({
getSubjects: vi.fn(),
updateAccessMode: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appDeploy: {
accessService: {
getEnvironmentSite: {
queryOptions: ({
input,
}: {
input: { params: { app_id: string; environment_id: string } }
}) => ({
queryKey: ['environment-site', input.params.app_id, input.params.environment_id],
}),
},
getEnvironmentWebAppSubjects: {
queryOptions: ({
input,
}: {
input: { params: { app_id: string; environment_id: string } }
}) => ({
queryKey: ['environment-subjects', input.params.app_id, input.params.environment_id],
queryFn: () => mocks.getSubjects(input),
}),
},
updateEnvironmentWebAppAccessMode: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.updateAccessMode,
...options,
}),
},
},
},
},
},
}))
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: ['system-features'],
queryFn: vi.fn(),
}),
}))
vi.mock('@/service/access-control', () => ({
useSearchForWhiteListCandidates: () => ({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [] },
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
function renderAccessControl(ui: ReactElement) {
const queryClient = createTestQueryClient()
queryClient.setQueryData(['system-features'], {
webapp_auth: {
enabled: true,
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
allow_public_access: true,
},
})
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
describe('EnvironmentAccessControl', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getSubjects.mockResolvedValue({
subjects: [
{
account_data: {
email: 'ada@example.com',
id: 'account-1',
name: 'Ada',
},
subject_id: 'account-1',
subject_type: 'account',
},
],
})
mocks.updateAccessMode.mockResolvedValue({
access_mode: 'private',
enabled: true,
})
})
it('should submit only subjects loaded from the environment endpoint', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
renderAccessControl(
<EnvironmentAccessControl
appId="app-1"
environmentId="staging"
accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS}
canManage
onClose={vi.fn()}
onConfirm={onConfirm}
/>,
)
expect(await screen.findByText('Ada')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mocks.updateAccessMode.mock.calls[0]?.[0]).toEqual({
params: {
app_id: 'app-1',
environment_id: 'staging',
},
body: {
access_mode: 'private',
subjects: [
{
subject_id: 'account-1',
subject_type: 'account',
},
],
},
})
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})
it('should allow authenticated external users to access the environment Web app', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
renderAccessControl(
<EnvironmentAccessControl
appId="app-1"
environmentId="staging"
accessMode={AccessMode.ORGANIZATION}
canManage
onClose={vi.fn()}
onConfirm={onConfirm}
/>,
)
await user.click(
screen.getByRole('radio', {
name: 'app.accessControlDialog.accessItems.external',
}),
)
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mocks.updateAccessMode.mock.calls[0]?.[0]).toEqual({
params: {
app_id: 'app-1',
environment_id: 'staging',
},
body: {
access_mode: AccessMode.EXTERNAL_MEMBERS,
},
})
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})
it('should keep confirmation disabled when the environment subjects query fails', async () => {
const user = userEvent.setup()
mocks.getSubjects.mockRejectedValue(new Error('subjects unavailable'))
renderAccessControl(
<EnvironmentAccessControl
appId="app-1"
environmentId="staging"
accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS}
canManage
onClose={vi.fn()}
onConfirm={vi.fn()}
/>,
)
expect(await screen.findByRole('alert')).toHaveTextContent('common.dynamicSelect.error')
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
expect(confirmButton).toBeDisabled()
await user.click(confirmButton)
expect(mocks.updateAccessMode).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,403 @@
import type { ReactElement } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import { createTestQueryClient } from '@/test/query-client'
import { EnvironmentServiceApiCard } from '../deployed-environment-access-points/environment-service-api-card'
import { EnvironmentWebAppCard } from '../deployed-environment-access-points/environment-web-app-card'
const mocks = vi.hoisted(() => ({
getApi: vi.fn(),
getSite: vi.fn(),
getSubjects: vi.fn(),
resetSite: vi.fn(),
updateApi: vi.fn(),
updateSite: vi.fn(),
environmentAccessControlProps: vi.fn(),
apiKeyButtonProps: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appDeploy: {
accessService: {
getEnvironmentApi: {
queryOptions: ({
input,
}: {
input: { params: { app_id: string; environment_id: string } }
}) => ({
queryKey: ['environment-api', input.params.app_id, input.params.environment_id],
queryFn: () => mocks.getApi(input),
}),
},
getEnvironmentSite: {
queryOptions: ({
input,
}: {
input: { params: { app_id: string; environment_id: string } }
}) => ({
queryKey: ['environment-site', input.params.app_id, input.params.environment_id],
queryFn: () => mocks.getSite(input),
}),
},
getEnvironmentWebAppSubjects: {
queryOptions: ({
input,
}: {
input: { params: { app_id: string; environment_id: string } }
}) => ({
queryKey: ['environment-subjects', input.params.app_id, input.params.environment_id],
queryFn: () => mocks.getSubjects(input),
}),
},
resetEnvironmentSiteAccessToken: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.resetSite,
...options,
}),
},
updateEnvironmentApi: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.updateApi,
...options,
}),
},
updateEnvironmentSite: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.updateSite,
...options,
}),
},
},
},
},
},
}))
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: ['system-features'],
queryFn: vi.fn(),
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.test/en${path}`,
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
appDetail: {
id: 'app-1',
icon: '🤖',
icon_background: '#FFEAD5',
icon_type: 'emoji',
icon_url: null,
mode: 'workflow',
site: {
access_token: 'built-in-code',
app_base_url: 'https://built-in.example.test',
},
},
}),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: () => <div aria-label="app-icon" />,
}))
vi.mock('@/app/components/app/access-point/shared/use-access-point-actions', () => ({
useAccessPointActions: () => ({
saveSiteConfig: vi.fn(),
}),
}))
vi.mock('@/app/components/app/overview/customize', () => ({
default: ({ api_base_url, isShow }: { api_base_url: string; isShow: boolean }) =>
isShow ? (
<div role="dialog" aria-label="environment customize">
{api_base_url}
</div>
) : null,
}))
vi.mock('@/app/components/app/overview/settings', () => ({
default: ({ isShow }: { isShow: boolean }) =>
isShow ? <div role="dialog" aria-label="environment settings" /> : null,
}))
vi.mock('../deployed-environment-access-points/environment-access-control', () => ({
EnvironmentAccessControl: (props: {
appId: string
environmentId: string
accessMode: string
canManage: boolean
}) => {
mocks.environmentAccessControlProps(props)
return <div role="dialog" aria-label="environment access mode" />
},
}))
vi.mock('@/app/components/app/access-point/shared/api-secret-key-button', () => ({
ApiSecretKeyButton: (props: {
apiKeyCount?: number
appId: string
canManage: boolean
disabled?: boolean
environmentId?: string
}) => {
mocks.apiKeyButtonProps(props)
return (
<button type="button" disabled={!props.canManage || props.disabled}>
environment-api-keys
</button>
)
},
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
const environmentParams = {
app_id: 'app-1',
environment_id: 'staging',
}
const site = {
access_mode: 'private',
app_base_url: 'https://site.example.test',
code: 'site-code',
enabled: true,
}
const api = {
api_key_count: 3,
base_url: 'https://api.example.test/v1',
enabled: true,
}
function renderCard(ui: ReactElement) {
const queryClient = createTestQueryClient()
queryClient.setQueryData(['system-features'], {
webapp_auth: {
enabled: true,
},
})
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
describe('environment access point cards', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getApi.mockResolvedValue(api)
mocks.getSite.mockResolvedValue(site)
mocks.getSubjects.mockResolvedValue({
subjects: [
{
account_data: {
email: 'ada@example.com',
id: 'account-1',
name: 'Ada',
},
subject_id: 'account-1',
subject_type: 'account',
},
],
})
mocks.resetSite.mockResolvedValue({
...site,
code: 'regenerated-code',
})
mocks.updateApi.mockResolvedValue({
...api,
enabled: false,
})
mocks.updateSite.mockResolvedValue({
...site,
enabled: false,
})
})
it('renders the real environment Web app URL and workflow actions without Embed', async () => {
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
expect(await screen.findByText(/env\/workflow\/site-code/)).toHaveTextContent(
'https://site.example.test/env/workflow/site-code',
)
expect(
await screen.findByRole('button', {
name: /accessControlDialog\.accessItems\.specific/,
}),
).toBeEnabled()
expect(screen.queryByRole('button', { name: /embedIntoSite/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeEnabled()
expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeEnabled()
})
it('renders authenticated external users as the environment Web app access mode', async () => {
mocks.getSite.mockResolvedValue({
...site,
access_mode: 'sso_verified',
})
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
expect(
await screen.findByRole('button', {
name: /accessControlDialog\.accessItems\.external/,
}),
).toBeEnabled()
})
it('shows the environment Web app query as loading instead of failed', () => {
mocks.getSite.mockImplementation(() => new Promise(() => {}))
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
const card = screen.getByRole('region', { name: /webApp\.title/ })
expect(card).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
})
it('uses environment Site mutations for status and URL reset, and opens its access container', async () => {
const user = userEvent.setup()
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
const accessModeButton = await screen.findByRole('button', {
name: /accessControlDialog\.accessItems\.specific/,
})
await user.click(accessModeButton)
await waitFor(() => {
expect(mocks.environmentAccessControlProps).toHaveBeenLastCalledWith(
expect.objectContaining({
appId: 'app-1',
environmentId: 'staging',
accessMode: 'private',
canManage: true,
}),
)
})
await user.click(screen.getByRole('button', { name: /regenerate/ }))
await user.click(screen.getByRole('button', { name: /operation\.confirm/ }))
await waitFor(() => {
expect(mocks.resetSite.mock.calls[0]?.[0]).toEqual({
params: environmentParams,
})
})
await user.click(screen.getByRole('switch'))
await waitFor(() => {
expect(mocks.updateSite.mock.calls[0]?.[0]).toEqual({
body: {
enabled: false,
},
params: environmentParams,
})
})
})
it('opens Customize and Settings with environment endpoint data', async () => {
const user = userEvent.setup()
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
await screen.findByText(/env\/workflow\/site-code/)
await user.click(screen.getByRole('button', { name: /customize\.entry/ }))
expect(screen.getByRole('dialog', { name: 'environment customize' })).toHaveTextContent(
'https://api.example.test/v1',
)
await user.click(screen.getByRole('button', { name: /settings\.settings/ }))
expect(screen.getByRole('dialog', { name: 'environment settings' })).toBeInTheDocument()
})
it('renders the real Service API endpoint, environment keys entry, docs entry, and API toggle', async () => {
const user = userEvent.setup()
renderCard(<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManage />)
expect(await screen.findByText(api.base_url)).toBeInTheDocument()
expect(mocks.apiKeyButtonProps).toHaveBeenLastCalledWith(
expect.objectContaining({
apiKeyCount: 3,
appId: 'app-1',
canManage: true,
environmentId: 'staging',
}),
)
expect(screen.getByRole('button', { name: 'environment-api-keys' })).toBeInTheDocument()
const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).toHaveAttribute(
'href',
'https://docs.example.test/en/api-reference/guides/workflow',
)
expect(apiReferenceLink).toHaveAttribute('target', '_blank')
expect(apiReferenceLink).toHaveAttribute('rel', 'noopener noreferrer')
await user.click(screen.getByRole('switch'))
await waitFor(() => {
expect(mocks.updateApi.mock.calls[0]?.[0]).toEqual({
body: {
enabled: false,
},
params: environmentParams,
})
})
})
it('keeps environment API keys and external documentation available when the API is stopped', async () => {
mocks.getApi.mockResolvedValue({
...api,
enabled: false,
})
renderCard(<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManage />)
await screen.findByText(api.base_url)
expect(await screen.findByRole('button', { name: 'environment-api-keys' })).toBeEnabled()
const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).not.toHaveAttribute('aria-disabled')
expect(apiReferenceLink).toHaveAttribute(
'href',
'https://docs.example.test/en/api-reference/guides/workflow',
)
})
it('distinguishes the Service API loading and failed query states', async () => {
mocks.getApi.mockRejectedValue(new Error('API unavailable'))
renderCard(<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManage />)
const card = screen.getByRole('region', { name: /serviceApi\.title/ })
expect(card).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
expect(await screen.findAllByText('deployments.health.ENVIRONMENT_STATUS_FAILED')).toHaveLength(
2,
)
expect(card).not.toHaveAttribute('aria-busy')
expect(screen.queryByText('common.loading')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'environment-api-keys' })).toBeDisabled()
expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toHaveAttribute(
'aria-disabled',
'true',
)
})
})
@@ -0,0 +1,279 @@
import type { AppEnvironment } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { ReactNode } from 'react'
import type { AccessPoint as AccessPointType } from '@/app/components/app/deploy/access-point'
import { EnvironmentStatus } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { consoleQuery } from '@/service/client'
import { seedAccountProfileQuery } from '@/test/console/account-profile'
import { QueryClientTestProvider } from '@/test/console/query-provider'
import { render } from '@/test/console/render'
import { createTestQueryClient } from '@/test/query-client'
import { AppACLPermission } from '@/utils/permission'
import AccessPoint from '..'
let appMode = 'workflow'
let appPermissionKeys: string[] = [AppACLPermission.Deploy]
const mockConsoleState = vi.hoisted(() => ({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[],
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
'workflow.nodes.common.memories.builtIn': 'Built-in',
})
})
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
appDetail: {
id: 'app-1',
mode: appMode,
maintainer: 'user-2',
permission_keys: appPermissionKeys,
},
}),
}))
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/app/components/app/access-point/built-in-access-points', () => ({
BuiltInAccessPoints: ({
appId,
highlightedAccessPoint,
}: {
appId: string
highlightedAccessPoint?: AccessPointType
}) => (
<div
data-testid="built-in-access-points"
data-highlighted-access-point={highlightedAccessPoint}
>
{appId}
</div>
),
}))
vi.mock('@/app/components/app/access-point/deployed-environment-access-points', () => ({
DeployedEnvironmentAccessPoints: ({
appId,
canEdit,
canManage,
environmentId,
highlightedAccessPoint,
}: {
appId: string
canEdit: boolean
canManage: boolean
environmentId: string
highlightedAccessPoint?: AccessPointType
}) => (
<div
data-testid="deployed-environment-access-points"
data-app-id={appId}
data-can-edit={String(canEdit)}
data-can-manage={String(canManage)}
data-highlighted-access-point={highlightedAccessPoint}
>
{environmentId}
</div>
),
}))
const appEnvironments: AppEnvironment[] = [
{
id: 'staging',
display_name: 'Staging',
description: '',
status: EnvironmentStatus.ENVIRONMENT_STATUS_READY,
in_use: true,
},
{
id: 'canary',
display_name: 'Canary',
description: '',
status: EnvironmentStatus.ENVIRONMENT_STATUS_READY,
in_use: true,
},
{
id: 'qa',
display_name: 'Quality Assurance',
description: '',
status: EnvironmentStatus.ENVIRONMENT_STATUS_READY,
in_use: false,
},
]
const renderAccessPoint = ({
environments = appEnvironments,
searchParams = '',
}: {
environments?: AppEnvironment[]
searchParams?: string
} = {}) => {
const queryClient = createTestQueryClient()
seedAccountProfileQuery(queryClient, mockConsoleState.userProfile)
const queryOptions =
consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({
input: {
params: {
app_id: 'app-1',
},
},
})
queryClient.setQueryData(queryOptions.queryKey, { data: environments })
const onUrlUpdate = vi.fn()
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientTestProvider queryClient={queryClient}>
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
</QueryClientTestProvider>
)
return {
...render(<AccessPoint appId="app-1" />, { wrapper: Wrapper }),
onUrlUpdate,
}
}
describe('AccessPoint', () => {
beforeEach(() => {
appMode = 'workflow'
appPermissionKeys = [AppACLPermission.Deploy]
})
it('renders Built-in and only in-use environments from the API', () => {
renderAccessPoint()
expect(screen.getByRole('heading', { name: 'common.appMenus.accessPoint' })).toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toHaveTextContent('app-1')
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
'Built-in',
'Staging',
'Canary',
])
expect(screen.queryByRole('tab', { name: 'Quality Assurance' })).not.toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true')
})
it('persists the selected environment in the URL', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderAccessPoint()
await user.click(screen.getByRole('tab', { name: 'Canary' }))
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: '?environment=canary',
}),
)
})
it('selects the target environment and highlights its access point from the URL', () => {
renderAccessPoint({
searchParams: '?environment=canary&accessPoint=serviceApi',
})
expect(screen.getByRole('tab', { name: 'Canary' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByTestId('deployed-environment-access-points')).toHaveTextContent('canary')
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute(
'data-highlighted-access-point',
'serviceApi',
)
})
it('highlights a built-in access point from the URL', () => {
renderAccessPoint({
searchParams: '?environment=built-in&accessPoint=mcp',
})
expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByTestId('built-in-access-points')).toHaveAttribute(
'data-highlighted-access-point',
'mcp',
)
})
it('clears the access point highlight when switching environment tabs', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderAccessPoint({
searchParams: '?environment=canary&accessPoint=serviceApi',
})
await user.click(screen.getByRole('tab', { name: 'Staging' }))
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: '?environment=staging',
}),
)
expect(screen.getByTestId('deployed-environment-access-points')).not.toHaveAttribute(
'data-highlighted-access-point',
)
})
it('shows the selected deployed environment with deploy permissions', () => {
renderAccessPoint({
searchParams: '?environment=canary',
})
expect(screen.getByTestId('deployed-environment-access-points')).toHaveTextContent('canary')
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute(
'data-app-id',
'app-1',
)
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute(
'data-can-edit',
'false',
)
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute(
'data-can-manage',
'true',
)
expect(screen.queryByTestId('built-in-access-points')).not.toBeInTheDocument()
})
it('falls back to Built-in when the URL targets an unused environment', () => {
renderAccessPoint({
searchParams: '?environment=qa&accessPoint=mcp',
})
expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true')
expect(screen.queryByRole('tab', { name: 'Quality Assurance' })).not.toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).not.toHaveAttribute(
'data-highlighted-access-point',
)
expect(screen.queryByTestId('deployed-environment-access-points')).not.toBeInTheDocument()
})
it('hides environment tabs for app types without multi-environment support', () => {
appMode = 'chat'
renderAccessPoint()
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument()
})
it('falls back to built-in access points without app deploy ACL permission', () => {
appPermissionKeys = []
renderAccessPoint({
searchParams: '?environment=canary',
})
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument()
expect(screen.queryByTestId('deployed-environment-access-points')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,164 @@
import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '@/app/components/workflow/types'
import { render } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { MCPAccessPointCard } from '../built-in-access-points/mcp-card'
const mocks = vi.hoisted(() => ({
invalidateServerDetail: vi.fn(),
serverDetail: {
data: undefined as undefined | { id: string; server_code: string; status: string },
isPending: false,
},
modalProps: vi.fn(),
refreshServerCode: vi.fn(),
updateServer: vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useInvalidateMCPServerDetail: () => mocks.invalidateServerDetail,
useMCPServerDetail: () => mocks.serverDetail,
useRefreshMCPServerCode: () => ({
isPending: false,
mutateAsync: mocks.refreshServerCode,
}),
useUpdateMCPServer: () => ({
isPending: false,
mutateAsync: mocks.updateServer,
}),
}))
vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({
default: (props: Record<string, unknown>) => {
mocks.modalProps(props)
return <div role="dialog" aria-label="MCP server settings" />
},
}))
const appInfo = {
api_base_url: 'https://api.example.test/v1',
id: 'app-1',
mode: AppModeEnum.CHAT,
model_config: {
updated_at: 1_710_000_000,
user_input_form: [
{
'text-input': {
label: 'Question',
required: true,
variable: 'question',
},
},
],
},
} as AccessPointAppInfo
const workflowAppInfo = {
...appInfo,
mode: AppModeEnum.WORKFLOW,
model_config: null,
} as unknown as AccessPointAppInfo
const publishedWorkflow = {
graph: {
nodes: [
{
data: {
type: BlockEnum.Start,
variables: [{ label: 'Query', variable: 'query' }],
},
},
],
},
} as unknown as PublishedWorkflow
describe('MCPAccessPointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.serverDetail.data = undefined
mocks.serverDetail.isPending = false
})
afterEach(() => {
vi.restoreAllMocks()
})
it('uses the provided basic app model config without refetching app detail', async () => {
const user = userEvent.setup()
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('{}', { status: 200 }))
render(
<MCPAccessPointCard
appInfo={appInfo}
canEdit
triggerModeDisabled={false}
workflow={undefined}
workflowLoading={false}
/>,
)
await user.click(screen.getByRole('button', { name: /addDescription/ }))
expect(screen.getByRole('dialog', { name: 'MCP server settings' })).toBeInTheDocument()
expect(mocks.modalProps).toHaveBeenCalledWith(
expect.objectContaining({
latestParams: [
{
label: 'Question',
required: true,
type: 'text-input',
variable: 'question',
},
],
}),
)
expect(fetchSpy).not.toHaveBeenCalled()
})
it('uses workflow inputs when the app model config is null', async () => {
const user = userEvent.setup()
render(
<MCPAccessPointCard
appInfo={workflowAppInfo}
canEdit
triggerModeDisabled={false}
workflow={publishedWorkflow}
workflowLoading={false}
/>,
)
await user.click(screen.getByRole('button', { name: /addDescription/ }))
expect(mocks.modalProps).toHaveBeenCalledWith(
expect.objectContaining({
latestParams: [{ label: 'Query', variable: 'query' }],
}),
)
})
it('shows loading without reporting an environment failure', () => {
mocks.serverDetail.isPending = true
render(
<MCPAccessPointCard
appInfo={workflowAppInfo}
canEdit
triggerModeDisabled={false}
workflow={publishedWorkflow}
workflowLoading={false}
/>,
)
const card = screen.getByRole('region', { name: /mcp\.server\.title/ })
expect(card).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
})
})
@@ -0,0 +1,120 @@
import type { AccessPointAppInfo } from '../shared/utils'
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { ServiceApiAccessPointCard } from '../built-in-access-points/service-api-card'
const mocks = vi.hoisted(() => ({
apiSecretKeyButtonProps: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.test/en${path}`,
}))
vi.mock('../shared/api-secret-key-button', () => ({
ApiSecretKeyButton: (props: { canManage: boolean; disabled?: boolean }) => {
mocks.apiSecretKeyButtonProps(props)
return (
<button type="button" disabled={!props.canManage || props.disabled}>
api-secret-keys
</button>
)
},
}))
function createAppInfo(
mode: AppModeEnum,
overrides: Partial<AccessPointAppInfo> = {},
): AccessPointAppInfo {
return {
api_base_url: 'https://api.example.test/v1',
enable_api: true,
id: 'app-1',
mode,
...overrides,
} as AccessPointAppInfo
}
describe('ServiceApiAccessPointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each([
[AppModeEnum.ADVANCED_CHAT, '/api-reference/guides/chatflow'],
[AppModeEnum.WORKFLOW, '/api-reference/guides/workflow'],
[AppModeEnum.CHAT, '/api-reference/guides/chat'],
[AppModeEnum.AGENT_CHAT, '/api-reference/guides/chat'],
[AppModeEnum.COMPLETION, '/api-reference/guides/completion'],
])('links %s apps to the matching external API reference', (mode, path) => {
render(
<ServiceApiAccessPointCard
appInfo={createAppInfo(mode)}
availability="available"
canEdit
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).toHaveAttribute('href', `https://docs.example.test/en${path}`)
expect(apiReferenceLink).toHaveAttribute('target', '_blank')
expect(apiReferenceLink).toHaveAttribute('rel', 'noopener noreferrer')
})
it('shows loading without reporting an environment failure', () => {
render(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
availability="loading"
canEdit
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
const card = screen.getByRole('region', { name: /serviceApi\.title/ })
expect(card).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
})
it('keeps API keys and external documentation available when the API is stopped', () => {
render(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW, { enable_api: false })}
availability="available"
canEdit
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeEnabled()
const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).not.toHaveAttribute('aria-disabled')
expect(apiReferenceLink).toHaveAttribute(
'href',
'https://docs.example.test/en/api-reference/guides/workflow',
)
})
it('disables API keys and external documentation when the access point is unavailable', () => {
render(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
availability="unavailable"
canEdit
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeDisabled()
expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toHaveAttribute(
'aria-disabled',
'true',
)
})
})
@@ -0,0 +1,148 @@
import type { AccessPointAppInfo } from '../shared/utils'
import type { AppTrigger } from '@/service/use-tools'
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { TriggerAccessPointCard } from '../built-in-access-points/trigger-card'
const mocks = vi.hoisted(() => ({
invalidateTriggers: vi.fn(),
setTriggerStatus: vi.fn(),
setTriggerStatuses: vi.fn(),
triggerQuery: {
data: undefined as { data: AppTrigger[] } | undefined,
isLoading: false,
},
updateTriggerStatus: vi.fn(),
}))
vi.mock('@/app/components/workflow/store/trigger-status', () => ({
useTriggerStatusStore: () => ({
setTriggerStatus: mocks.setTriggerStatus,
setTriggerStatuses: mocks.setTriggerStatuses,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.test/en${path}`,
}))
vi.mock('@/service/use-tools', () => ({
useAppTriggers: () => mocks.triggerQuery,
useInvalidateAppTriggers: () => mocks.invalidateTriggers,
useUpdateTriggerStatus: () => ({
isPending: false,
mutateAsync: mocks.updateTriggerStatus,
}),
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: () => ({ data: [] }),
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => null,
}))
const appInfo = {
id: 'app-1',
mode: AppModeEnum.WORKFLOW,
} as AccessPointAppInfo
function createTrigger(id: string, status: AppTrigger['status']): AppTrigger {
return {
id,
trigger_type: 'trigger-webhook',
title: `Trigger ${id}`,
node_id: `node-${id}`,
provider_name: 'Webhook',
icon: '',
status,
created_at: '2026-08-04T00:00:00Z',
updated_at: '2026-08-04T00:00:00Z',
}
}
function renderCard(availability: 'available' | 'loading' | 'unavailable') {
render(
<TriggerAccessPointCard
appInfo={appInfo}
availability={availability}
canEdit
onToggleResult={vi.fn()}
/>,
)
}
describe('TriggerAccessPointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.triggerQuery.data = undefined
mocks.triggerQuery.isLoading = false
})
it('shows loading without reporting an environment failure', () => {
renderCard('loading')
const card = screen.getByRole('region', { name: /settings\.trigger/ })
expect(card).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
})
it('shows every non-enabled trigger as disabled with its switch off', () => {
mocks.triggerQuery.data = {
data: [
createTrigger('enabled', 'enabled'),
createTrigger('disabled', 'disabled'),
createTrigger('unauthorized', 'unauthorized'),
],
}
renderCard('available')
expect(
screen.getByText(
'deployments.studio.accessPoint.triggerEnabledCount:{"enabled":1,"total":3}',
),
).toBeInTheDocument()
expect(screen.getByText('agentV2.agentDetail.access.status.inService')).toBeInTheDocument()
expect(screen.getAllByText('appOverview.overview.status.disable')).toHaveLength(2)
expect(
screen.queryByText('deployments.studio.accessPoint.triggerDisconnected'),
).not.toBeInTheDocument()
expect(
screen.queryByText('deployments.studio.accessPoint.triggerMuted'),
).not.toBeInTheDocument()
const [enabledSwitch, disabledSwitch, unauthorizedSwitch] = screen.getAllByRole('switch')
expect(enabledSwitch).toBeChecked()
expect(disabledSwitch).not.toBeChecked()
expect(unauthorizedSwitch).not.toBeChecked()
})
it('uses the overview empty-state copy and documentation interaction', () => {
renderCard('unavailable')
expect(
screen.getByText('appOverview.overview.triggerInfo.triggerStatusDescription'),
).toBeInTheDocument()
const learnLink = screen.getByRole('link', {
name: 'appOverview.overview.triggerInfo.learnAboutTriggers',
})
expect(learnLink).toHaveAttribute(
'href',
'https://docs.example.test/en/use-dify/nodes/trigger/overview',
)
expect(learnLink).toHaveAttribute('target', '_blank')
expect(learnLink).toHaveAttribute('rel', 'noopener noreferrer')
expect(
screen.queryByText('deployments.studio.accessPoint.triggerServiceModeUnavailable'),
).not.toBeInTheDocument()
expect(
screen.queryByText('deployments.studio.accessPoint.noTriggerNodes'),
).not.toBeInTheDocument()
})
})
@@ -0,0 +1,200 @@
import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils'
import type { InputVar, Node } from '@/app/components/workflow/types'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { AccessMode } from '@/models/access-control'
import { render } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import { WebAppAccessPointCard } from '../built-in-access-points/web-app-card'
vi.mock('@/service/access-control/use-app-access-control', () => ({
useAppWhiteListSubjects: () => ({
data: undefined,
}),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: () => <div aria-label="app-icon" />,
}))
vi.mock('@/app/components/app/app-access-control', () => ({
default: () => null,
}))
vi.mock('@/app/components/app/overview/customize', () => ({
default: () => null,
}))
vi.mock('@/app/components/app/overview/settings', () => ({
default: () => null,
}))
vi.mock('@/app/components/app/overview/embedded', () => ({
default: ({
hiddenInputs = [],
isShow,
}: {
hiddenInputs?: Array<{ variable: string }>
isShow: boolean
}) =>
isShow ? (
<div role="dialog" aria-label="embed into site">
{hiddenInputs.map((input) => input.variable).join(',')}
</div>
) : null,
}))
function createAppInfo(mode: AppModeEnum): AccessPointAppInfo {
return {
access_mode: AccessMode.PUBLIC,
api_base_url: 'https://api.example.test/v1',
enable_site: true,
icon: '🤖',
icon_background: '#FFEAD5',
icon_type: 'emoji',
icon_url: null,
id: 'app-1',
mode,
site: {
access_token: 'site-code',
app_base_url: 'https://site.example.test',
},
} as AccessPointAppInfo
}
function renderCard(
mode: AppModeEnum,
availability: 'available' | 'loading' | 'unavailable' = 'available',
workflow?: PublishedWorkflow,
) {
render(
<WebAppAccessPointCard
appInfo={createAppInfo(mode)}
availability={availability}
canEdit
canDeploy
canManageAccess
showAccessControl
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
onRefreshApp={vi.fn().mockResolvedValue(undefined)}
onRegenerate={vi.fn().mockResolvedValue(undefined)}
onSaveSiteConfig={vi.fn().mockResolvedValue(undefined)}
workflow={workflow}
/>,
)
}
const startNode: Node<{ variables: InputVar[] }> = {
id: 'start',
position: { x: 0, y: 0 },
data: {
title: 'Start',
desc: '',
type: BlockEnum.Start,
variables: [
{
variable: 'secret',
label: 'Secret',
type: InputVarType.textInput,
hide: true,
required: true,
default: '',
},
],
},
}
const workflowWithHiddenInput: NonNullable<PublishedWorkflow> = {
conversation_variables: [],
environment_variables: [],
features: {},
id: 'workflow-id',
graph: {
nodes: [startNode],
edges: [],
},
created_at: 0,
created_by: { id: 'user-id', name: 'User', email: 'user@example.com' },
hash: 'workflow-hash',
updated_at: 0,
updated_by: { id: 'user-id', name: 'User', email: 'user@example.com' },
tool_published: false,
version: '1',
marked_name: '',
marked_comment: '',
rag_pipeline_variables: [],
}
describe('WebAppAccessPointCard', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('shows the current access mode without a redundant section label', () => {
renderCard(AppModeEnum.CHAT)
expect(screen.queryByText(/publishApp\.title/)).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: /accessControlDialog\.accessItems\.anyone/ }),
).toBeEnabled()
})
it.each([AppModeEnum.WORKFLOW, AppModeEnum.COMPLETION])(
'does not offer Embed into site for %s apps',
(mode) => {
renderCard(mode)
expect(screen.queryByRole('button', { name: /embedIntoSite/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeInTheDocument()
},
)
it('keeps Embed into site for non-workflow Web apps', async () => {
const user = userEvent.setup()
renderCard(AppModeEnum.CHAT)
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
expect(screen.getByRole('dialog', { name: 'embed into site' })).toBeInTheDocument()
})
it('passes hidden Chatflow inputs to the embed dialog', async () => {
const user = userEvent.setup()
renderCard(AppModeEnum.ADVANCED_CHAT, 'available', workflowWithHiddenInput)
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
expect(screen.getByRole('dialog', { name: 'embed into site' })).toHaveTextContent('secret')
})
it('configures hidden workflow inputs before opening the Web App', async () => {
const user = userEvent.setup()
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
renderCard(AppModeEnum.WORKFLOW, 'available', workflowWithHiddenInput)
await user.click(screen.getByRole('button', { name: /operation\.config/ }))
await user.type(screen.getByLabelText('Secret'), 'top-secret')
await user.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/ }))
await waitFor(() => {
expect(openSpy).toHaveBeenCalledWith(
`https://site.example.test${basePath}/workflow/site-code?secret=top-secret`,
'_blank',
)
})
})
it('shows loading without reporting an environment failure', () => {
renderCard(AppModeEnum.WORKFLOW, 'loading')
const card = screen.getByRole('region', { name: /webApp\.title/ })
expect(card).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
})
})
@@ -0,0 +1,162 @@
'use client'
import type { AccessPoint } from '@/app/components/app/deploy/access-point'
import { Button } from '@langgenius/dify-ui/button'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { useDocLink } from '@/context/i18n'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
import { useAppWorkflow } from '@/service/use-workflow'
import { getAppACLCapabilities } from '@/utils/permission'
import { useAccessPointActions } from '../shared/use-access-point-actions'
import { getPublishedWorkflowState, isAdvancedApp } from '../shared/utils'
import { MCPAccessPointCard } from './mcp-card'
import { ServiceApiAccessPointCard } from './service-api-card'
import { TriggerAccessPointCard } from './trigger-card'
import { WebAppAccessPointCard } from './web-app-card'
type BuiltInAccessPointsProps = {
appId: string
highlightedAccessPoint?: AccessPoint | null
}
export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAccessPointsProps) {
const { t } = useTranslation()
const docLink = useDocLink()
const appInfo = useAppStore((state) => state.appDetail)
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo))
const { data: workflow, isPending: workflowLoading } = useAppWorkflow(
shouldFetchWorkflow ? appId : '',
)
const capabilities = useMemo(
() =>
getAppACLCapabilities(appInfo?.permission_keys, {
currentUserId,
resourceMaintainer: appInfo?.maintainer,
workspacePermissionKeys,
}),
[appInfo?.maintainer, appInfo?.permission_keys, currentUserId, workspacePermissionKeys],
)
const actions = useAccessPointActions(appId, capabilities.canEdit)
if (!appInfo) return <Loading />
const workflowState = getPublishedWorkflowState(appInfo, workflow)
const builtInLoading = workflowState.isWorkflowApp && workflowLoading
const appCardsUnavailable =
workflowState.isWorkflowApp && (workflowState.isUnpublished || workflowState.hasTriggerNode)
const appCardAvailability = builtInLoading
? 'loading'
: appCardsUnavailable
? 'unavailable'
: 'available'
const triggerAvailability = builtInLoading
? 'loading'
: workflowState.isUnpublished || !workflowState.hasTriggerNode
? 'unavailable'
: 'available'
return (
<div className="flex h-full flex-col gap-2">
{workflowState.isUnpublished && !workflowLoading && (
<div className="flex flex-col items-start gap-2 rounded-xl bg-background-section-burn p-3">
<div className="flex flex-col gap-0.5">
<span className="block system-md-semibold text-text-secondary">
{t(($) => $['studio.accessPoint.noPublishedTitle'], {
ns: 'deployments',
})}
</span>
<span className="block system-xs-regular text-text-tertiary">
{t(($) => $['studio.accessPoint.noPublishedDescription'], {
ns: 'deployments',
})}
</span>
</div>
<Button
variant="primary"
size="medium"
disabled={!capabilities.canReleaseAndVersion}
render={<Link href={`/app/${appId}/workflow`} />}
className="flex items-center gap-1"
>
{t(($) => $['studio.accessPoint.goToPublish'], { ns: 'deployments' })}
<span aria-hidden className="i-ri-arrow-right-line size-4" />
</Button>
</div>
)}
<div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2">
<WebAppAccessPointCard
appInfo={appInfo}
availability={appCardAvailability}
canEdit={capabilities.canEdit}
canDeploy={capabilities.canDeploy}
canManageAccess={capabilities.canReleaseAndVersion}
showAccessControl={systemFeatures.webapp_auth.enabled}
onChangeStatus={actions.changeSiteStatus}
onRefreshApp={actions.refreshAppDetail}
onRegenerate={actions.regenerateSiteCode}
onSaveSiteConfig={actions.saveSiteConfig}
workflow={workflow}
highlighted={highlightedAccessPoint === 'webApp'}
/>
<ServiceApiAccessPointCard
appInfo={appInfo}
availability={appCardAvailability}
canEdit={capabilities.canEdit}
onChangeStatus={actions.changeApiStatus}
highlighted={highlightedAccessPoint === 'serviceApi'}
/>
<MCPAccessPointCard
appInfo={appInfo}
canEdit={capabilities.canEdit}
workflow={workflow}
workflowLoading={workflowLoading}
triggerModeDisabled={workflowState.hasTriggerNode}
highlighted={highlightedAccessPoint === 'mcp'}
/>
{workflowState.isWorkflowApp && (
<TriggerAccessPointCard
appInfo={appInfo}
availability={triggerAvailability}
canEdit={capabilities.canEdit}
onToggleResult={actions.handleResult}
highlighted={highlightedAccessPoint === 'trigger'}
/>
)}
</div>
{workflowState.hasTriggerNode && (
<div className="mt-2 flex min-h-10 items-center gap-2 rounded-xl bg-background-section-burn px-3 py-2 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-information-line size-4 shrink-0" />
<span>
{t(($) => $['studio.accessPoint.triggerExclusiveNotice'], {
ns: 'deployments',
})}{' '}
<Link
href={docLink('/use-dify/nodes/start')}
target="_blank"
rel="noopener noreferrer"
className="text-text-accent hover:underline"
>
{t(($) => $['operation.learnMore'], { ns: 'common' })}
</Link>
</span>
</div>
)}
</div>
)
}
@@ -0,0 +1,223 @@
'use client'
import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
import { BlockEnum } from '@/app/components/workflow/types'
import {
useInvalidateMCPServerDetail,
useMCPServerDetail,
useRefreshMCPServerCode,
useUpdateMCPServer,
} from '@/service/use-tools'
import { AppModeEnum } from '@/types/app'
import { AccessPointCard } from '../shared/access-point-card'
import { AccessPointUrl } from '../shared/access-point-url'
import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils'
type MCPAccessPointCardProps = {
appInfo: AccessPointAppInfo
canEdit: boolean
highlighted?: boolean
triggerModeDisabled: boolean
workflow: PublishedWorkflow
workflowLoading: boolean
}
export function MCPAccessPointCard({
appInfo,
canEdit,
highlighted,
triggerModeDisabled,
workflow,
workflowLoading,
}: MCPAccessPointCardProps) {
const { t } = useTranslation()
const advancedApp = isAdvancedApp(appInfo)
const basicApp = !advancedApp
const workflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const [showServerModal, setShowServerModal] = useState(false)
const [showRegenerate, setShowRegenerate] = useState(false)
const [pendingStatus, setPendingStatus] = useState<boolean | null>(null)
const basicConfig = appInfo.model_config
const basicAppInputForm = basicConfig?.user_input_form
const { data: detail, isPending: serverDetailLoading } = useMCPServerDetail(
appInfo.id,
Boolean(appInfo.id),
)
const { mutateAsync: updateServer, isPending: statusUpdating } = useUpdateMCPServer()
const { mutateAsync: refreshServerCode, isPending: regenerating } = useRefreshMCPServerCode()
const invalidateServerDetail = useInvalidateMCPServerDetail()
const serverPublished = Boolean(detail?.id)
const serverActivated = detail?.status === 'active'
const activated = pendingStatus ?? serverActivated
const serverUrl = serverPublished
? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${detail?.server_code}/mcp`
: '***********'
const workflowNodes = getPublishedWorkflowNodes(workflow)
const missingStartNode =
workflowApp && !workflowNodes.some((node) => node.data.type === BlockEnum.Start)
const appUnpublished = advancedApp ? !workflow?.graph : !basicConfig?.updated_at
const loading = serverDetailLoading || (advancedApp && workflowLoading)
const unavailable = !loading && (appUnpublished || missingStartNode || triggerModeDisabled)
const basicAppInputs = useMemo(() => {
if (!basicApp || !basicAppInputForm) return []
return basicAppInputForm.map((item) => {
const [type = 'text-input'] = Object.keys(item)
const [config = {}] = Object.values(item) as object[]
return {
...config,
type,
}
})
}, [basicApp, basicAppInputForm])
const latestParams = useMemo(() => {
if (!advancedApp) return basicAppInputs
const startNode = workflowNodes.find((node) => node.data.type === BlockEnum.Start)
return (
(
startNode?.data as {
variables?: Array<{ variable: string; label: string }>
}
)?.variables ?? []
)
}, [advancedApp, basicAppInputs, workflowNodes])
const handleStatusChange = async (enabled: boolean) => {
if (!canEdit || loading || unavailable) return
if (enabled && !serverPublished) {
setShowServerModal(true)
return
}
setPendingStatus(enabled)
try {
await updateServer({
appID: appInfo.id,
id: detail?.id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: enabled ? 'active' : 'inactive',
})
invalidateServerDetail(appInfo.id)
} finally {
setPendingStatus(null)
}
}
const handleRegenerate = async () => {
if (!canEdit || !detail?.id) return
await refreshServerCode(appInfo.id)
invalidateServerDetail(appInfo.id)
setShowRegenerate(false)
}
const status = loading
? 'loading'
: unavailable
? 'unavailable'
: activated
? 'inService'
: 'disabled'
return (
<>
<AccessPointCard
title={t(($) => $['mcp.server.title'], { ns: 'tools' })}
description={t(($) => $['studio.accessPoint.mcpDescription'], {
ns: 'deployments',
})}
icon="i-custom-vender-integrations-mcp"
status={status}
highlighted={highlighted}
busy={statusUpdating}
switchDisabled={!canEdit}
switchLabel={t(($) => $['mcp.server.title'], { ns: 'tools' })}
onEnabledChange={loading || unavailable ? undefined : handleStatusChange}
actions={
<Button
variant="secondary"
disabled={loading || unavailable || !canEdit}
onClick={() => setShowServerModal(true)}
className="flex items-center gap-1 px-3"
>
<span aria-hidden className="i-ri-draft-line size-4" />
{serverPublished
? t(($) => $['mcp.server.edit'], { ns: 'tools' })
: t(($) => $['mcp.server.addDescription'], { ns: 'tools' })}
</Button>
}
>
<AccessPointUrl
label={t(($) => $['mcp.server.url'], { ns: 'tools' })}
value={serverUrl}
enabled={activated}
copyDisabled={!serverPublished}
loading={loading}
unavailable={unavailable}
unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], {
ns: 'deployments',
})}
showRegenerate
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
ns: 'appOverview',
})}
regenerateDisabled={!canEdit || !serverPublished}
regenerating={regenerating}
onRegenerate={() => setShowRegenerate(true)}
/>
</AccessPointCard>
{showServerModal && (
<MCPServerModal
show
appID={appInfo.id}
data={serverPublished ? detail : undefined}
latestParams={latestParams}
onHide={() => {
setShowServerModal(false)
setPendingStatus(null)
invalidateServerDetail(appInfo.id)
}}
appInfo={appInfo}
/>
)}
<AlertDialog open={showRegenerate} onOpenChange={(open) => !open && setShowRegenerate(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t(($) => $['mcp.server.reGen'], { ns: 'tools' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={() => void handleRegenerate()}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,44 @@
'use client'
import type { AccessPointAvailability } from '../shared/access-point-status'
import type { AccessPointAppInfo } from '../shared/utils'
import { getAccessPointStatus } from '../shared/access-point-status'
import { ServiceApiCardView } from '../shared/service-api-card-view'
import { getBuiltInAccessUrls } from '../shared/utils'
type ServiceApiAccessPointCardProps = {
appInfo: AccessPointAppInfo
availability: AccessPointAvailability
canEdit: boolean
highlighted?: boolean
onChangeStatus: (enabled: boolean) => Promise<void>
}
export function ServiceApiAccessPointCard({
appInfo,
availability,
canEdit,
highlighted,
onChangeStatus,
}: ServiceApiAccessPointCardProps) {
const { api: apiUrl } = getBuiltInAccessUrls(appInfo)
const running = availability === 'available' && appInfo.enable_api
const status = getAccessPointStatus(availability, running)
return (
<ServiceApiCardView
apiKeyButtonProps={{
appId: appInfo.id,
canManage: canEdit,
disabled: availability !== 'available',
}}
apiUrl={apiUrl}
appMode={appInfo.mode}
available={availability === 'available'}
status={status}
highlighted={highlighted}
switchDisabled={!canEdit}
onEnabledChange={availability === 'available' ? onChangeStatus : undefined}
/>
)
}
@@ -0,0 +1,208 @@
'use client'
import type { AccessPointAppInfo } from '../shared/utils'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { AppTrigger } from '@/service/use-tools'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
import { BlockEnum } from '@/app/components/workflow/types'
import { useDocLink } from '@/context/i18n'
import Link from '@/next/link'
import {
useAppTriggers,
useInvalidateAppTriggers,
useUpdateTriggerStatus,
} from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { canFindTool } from '@/utils'
import { AccessPointCard, AccessPointEmptyContent } from '../shared/access-point-card'
function TriggerIcon({
trigger,
triggerPlugins,
}: {
trigger: AppTrigger
triggerPlugins: TriggerWithProvider[]
}) {
const blockType =
trigger.trigger_type === 'trigger-schedule'
? BlockEnum.TriggerSchedule
: trigger.trigger_type === 'trigger-plugin'
? BlockEnum.TriggerPlugin
: BlockEnum.TriggerWebhook
const pluginTrigger =
trigger.trigger_type === 'trigger-plugin' && trigger.provider_name
? triggerPlugins.find(
(candidate) =>
canFindTool(candidate.id, trigger.provider_name!) ||
candidate.id.includes(trigger.provider_name!) ||
candidate.name === trigger.provider_name,
)
: undefined
const toolIcon = typeof pluginTrigger?.icon === 'string' ? pluginTrigger.icon : undefined
return (
<span>
<BlockIcon type={blockType} size="md" toolIcon={toolIcon} />
</span>
)
}
type TriggerAccessPointCardProps = {
appInfo: AccessPointAppInfo
availability: 'available' | 'loading' | 'unavailable'
canEdit: boolean
highlighted?: boolean
onToggleResult: (error: Error | null) => void
}
export function TriggerAccessPointCard({
appInfo,
availability,
canEdit,
highlighted,
onToggleResult,
}: TriggerAccessPointCardProps) {
const { t } = useTranslation()
const docLink = useDocLink()
const { data: response, isLoading } = useAppTriggers(appInfo.id)
const { data: triggerPlugins = [] } = useAllTriggerPlugins()
const { mutateAsync: updateTriggerStatus, isPending: statusUpdating } = useUpdateTriggerStatus()
const invalidateTriggers = useInvalidateAppTriggers()
const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore()
const triggers = useMemo(() => response?.data ?? [], [response?.data])
const loading = availability === 'loading' || isLoading
const active = availability === 'available' && !loading
const status = loading ? 'loading' : active ? 'inService' : 'unavailable'
const enabledCount = triggers.filter((trigger) => trigger.status === 'enabled').length
useEffect(() => {
if (!triggers.length) return
setTriggerStatuses(
triggers.reduce(
(statuses, trigger) => {
statuses[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
return statuses
},
{} as Record<string, 'disabled' | 'enabled'>,
),
)
}, [setTriggerStatuses, triggers])
const toggleTrigger = async (trigger: AppTrigger, enabled: boolean) => {
if (!canEdit) return
const status = enabled ? 'enabled' : 'disabled'
setTriggerStatus(trigger.node_id, status)
try {
await updateTriggerStatus({
appId: appInfo.id,
triggerId: trigger.id,
enableTrigger: enabled,
})
invalidateTriggers(appInfo.id)
onToggleResult(null)
} catch (error) {
setTriggerStatus(trigger.node_id, enabled ? 'disabled' : 'enabled')
onToggleResult(error as Error)
}
}
return (
<AccessPointCard
title={t(($) => $['settings.trigger'], { ns: 'common' })}
description={t(($) => $['studio.accessPoint.triggerDescription'], {
ns: 'deployments',
})}
icon="i-custom-vender-integrations-trigger"
status={status}
highlighted={highlighted}
showStatus={!active}
busy={statusUpdating}
>
{loading && (
<div className="flex h-full min-h-40 flex-col gap-4 px-4 py-5">
<span className="h-2 w-24 animate-pulse rounded-full bg-text-quaternary opacity-20 motion-reduce:animate-none" />
<span className="h-10 w-full animate-pulse rounded-lg bg-text-quaternary opacity-10 motion-reduce:animate-none" />
<span className="h-10 w-full animate-pulse rounded-lg bg-text-quaternary opacity-10 motion-reduce:animate-none" />
</div>
)}
{!loading && (!active || triggers.length === 0) && (
<AccessPointEmptyContent>
<span>
{t(($) => $['overview.triggerInfo.triggerStatusDescription'], {
ns: 'appOverview',
})}{' '}
<Link
href={docLink('/use-dify/nodes/trigger/overview')}
target="_blank"
rel="noopener noreferrer"
className="text-text-accent hover:underline"
>
{t(($) => $['overview.triggerInfo.learnAboutTriggers'], {
ns: 'appOverview',
})}
</Link>
</span>
</AccessPointEmptyContent>
)}
{active && triggers.length > 0 && (
<div className="flex flex-col px-4 py-3">
<div className="flex h-6 items-center system-xs-medium-uppercase text-text-secondary">
{t(($) => $['studio.accessPoint.triggerEnabledCount'], {
ns: 'deployments',
enabled: enabledCount,
total: triggers.length,
})}
</div>
<div className="mt-1 flex flex-col gap-1">
{triggers.map((trigger) => {
const enabled = trigger.status === 'enabled'
const statusLabel = enabled
? t(($) => $['agentDetail.access.status.inService'], {
ns: 'agentV2',
})
: t(($) => $['overview.status.disable'], {
ns: 'appOverview',
})
return (
<div
key={trigger.id}
className="flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
>
<TriggerIcon trigger={trigger} triggerPlugins={triggerPlugins} />
<span className="w-28 shrink-0 truncate system-sm-medium text-text-secondary">
{trigger.title}
</span>
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
{trigger.provider_name}
</span>
<span
className={`flex shrink-0 items-center gap-1 system-xs-semibold-uppercase ${
enabled ? 'text-text-success' : 'text-text-tertiary'
}`}
>
<StatusDot size="small" status={enabled ? 'success' : 'disabled'} />
{statusLabel}
</span>
<Switch
checked={enabled}
disabled={!canEdit || statusUpdating}
aria-label={trigger.title}
onCheckedChange={(nextEnabled) => void toggleTrigger(trigger, nextEnabled)}
/>
</div>
)
})}
</div>
</div>
)}
</AccessPointCard>
)
}

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