mirror of
https://github.com/langgenius/dify.git
synced 2026-09-21 13:20:52 +08:00
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:
co-authored by
zhangx1n
autofix-ci[bot]
parent
ef8544b173
commit
7aba539e82
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -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
@@ -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
@@ -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.",
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
}
|
||||||
+55
-2
@@ -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, {
|
||||||
|
|||||||
-228
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
+79
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
+403
@@ -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
Reference in New Issue
Block a user