mirror of
https://github.com/langgenius/dify.git
synced 2026-05-30 13:47:52 +08:00
Compare commits
8 Commits
feat/snipp
...
feat/mcp-t
| Author | SHA1 | Date | |
|---|---|---|---|
| 585d292511 | |||
| d3fa24d7e3 | |||
| 91ac465982 | |||
| 9490d63c50 | |||
| ae538ced47 | |||
| 487249728b | |||
| 372a2e3e9c | |||
| 4939a9c33d |
2
.github/workflows/deploy-saas.yml
vendored
2
.github/workflows/deploy-saas.yml
vendored
@ -25,4 +25,4 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
|
||||
${{ vars.SSH_SCRIPT_SAAS_DEV || secrets.SSH_SCRIPT_SAAS_DEV }}
|
||||
|
||||
63
.github/workflows/style.yml
vendored
63
.github/workflows/style.yml
vendored
@ -95,6 +95,51 @@ jobs:
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Web tsslint
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: vp run lint:tss
|
||||
|
||||
- name: Web dead code check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: vp run knip
|
||||
|
||||
ts-common-style:
|
||||
name: TS Common
|
||||
runs-on: depot-ubuntu-24.04
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
web/**
|
||||
cli/**
|
||||
e2e/**
|
||||
sdks/nodejs-client/**
|
||||
packages/**
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
.nvmrc
|
||||
eslint.config.mjs
|
||||
.github/workflows/style.yml
|
||||
.github/actions/setup-web/**
|
||||
|
||||
- name: Setup web environment
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Restore ESLint cache
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
id: eslint-cache-restore
|
||||
@ -105,28 +150,14 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-
|
||||
|
||||
- name: Web style check
|
||||
- name: Style check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: .
|
||||
run: vp run lint:ci
|
||||
|
||||
- name: Web tsslint
|
||||
- name: Type check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: vp run lint:tss
|
||||
|
||||
- name: Web type check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: .
|
||||
run: vp run type-check
|
||||
|
||||
- name: Web dead code check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
run: vp run knip
|
||||
|
||||
- name: Save ESLint cache
|
||||
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
|
||||
@ -209,6 +209,11 @@ class MCPProviderBasePayload(BaseModel):
|
||||
configuration: dict[str, Any] | None = Field(default_factory=dict)
|
||||
headers: dict[str, Any] | None = Field(default_factory=dict)
|
||||
authentication: dict[str, Any] | None = Field(default_factory=dict)
|
||||
# M3 — user-identity forwarding (M2 backend already supports these on the
|
||||
# service layer). Defaults preserve pre-M3 behavior for clients that don't
|
||||
# send the fields yet.
|
||||
forward_user_identity: bool = False
|
||||
identity_mode: Literal["off", "idp_token"] = "off"
|
||||
|
||||
|
||||
class MCPProviderCreatePayload(MCPProviderBasePayload):
|
||||
@ -985,6 +990,8 @@ class ToolProviderMCPApi(Resource):
|
||||
headers=payload.headers or {},
|
||||
configuration=configuration,
|
||||
authentication=authentication,
|
||||
forward_user_identity=payload.forward_user_identity,
|
||||
identity_mode=payload.identity_mode,
|
||||
)
|
||||
|
||||
# 2) Try to fetch tools immediately after creation so they appear without a second save.
|
||||
@ -1052,6 +1059,8 @@ class ToolProviderMCPApi(Resource):
|
||||
configuration=configuration,
|
||||
authentication=authentication,
|
||||
validation_result=validation_result,
|
||||
forward_user_identity=payload.forward_user_identity,
|
||||
identity_mode=payload.identity_mode,
|
||||
)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@ -562,15 +562,16 @@ class WorkflowResponseConverter:
|
||||
outputs, outputs_truncated = self._truncate_mapping(encoded_outputs)
|
||||
metadata = self._merge_metadata(event.execution_metadata, snapshot)
|
||||
|
||||
if isinstance(event, QueueNodeSucceededEvent):
|
||||
status = WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
error_message = event.error
|
||||
elif isinstance(event, QueueNodeFailedEvent):
|
||||
status = WorkflowNodeExecutionStatus.FAILED
|
||||
error_message = event.error
|
||||
else:
|
||||
status = WorkflowNodeExecutionStatus.EXCEPTION
|
||||
error_message = event.error
|
||||
match event:
|
||||
case QueueNodeSucceededEvent():
|
||||
status = WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
error_message = event.error
|
||||
case QueueNodeFailedEvent():
|
||||
status = WorkflowNodeExecutionStatus.FAILED
|
||||
error_message = event.error
|
||||
case _:
|
||||
status = WorkflowNodeExecutionStatus.EXCEPTION
|
||||
error_message = event.error
|
||||
|
||||
return NodeFinishStreamResponse(
|
||||
task_id=task_id,
|
||||
|
||||
@ -91,26 +91,28 @@ class AppGeneratorTTSPublisher:
|
||||
)
|
||||
future_queue.put(futures_result)
|
||||
break
|
||||
elif isinstance(message.event, QueueAgentMessageEvent | QueueLLMChunkEvent):
|
||||
message_content = message.event.chunk.delta.message.content
|
||||
if not message_content:
|
||||
continue
|
||||
match message_content:
|
||||
case str():
|
||||
self.msg_text += message_content
|
||||
case list():
|
||||
for content in message_content:
|
||||
if not isinstance(content, TextPromptMessageContent):
|
||||
continue
|
||||
self.msg_text += content.data
|
||||
elif isinstance(message.event, QueueTextChunkEvent):
|
||||
self.msg_text += message.event.text
|
||||
elif isinstance(message.event, QueueNodeSucceededEvent):
|
||||
if message.event.outputs is None:
|
||||
continue
|
||||
output = message.event.outputs.get("output", "")
|
||||
if isinstance(output, str):
|
||||
self.msg_text += output
|
||||
else:
|
||||
match message.event:
|
||||
case QueueAgentMessageEvent() | QueueLLMChunkEvent():
|
||||
message_content = message.event.chunk.delta.message.content
|
||||
if not message_content:
|
||||
continue
|
||||
match message_content:
|
||||
case str():
|
||||
self.msg_text += message_content
|
||||
case list():
|
||||
for content in message_content:
|
||||
if not isinstance(content, TextPromptMessageContent):
|
||||
continue
|
||||
self.msg_text += content.data
|
||||
case QueueTextChunkEvent():
|
||||
self.msg_text += message.event.text
|
||||
case QueueNodeSucceededEvent():
|
||||
if message.event.outputs is None:
|
||||
continue
|
||||
output = message.event.outputs.get("output", "")
|
||||
if isinstance(output, str):
|
||||
self.msg_text += output
|
||||
self.last_message = message
|
||||
sentence_arr, text_tmp = self._extract_sentence(self.msg_text)
|
||||
if len(sentence_arr) >= min(self.max_sentence, 7):
|
||||
|
||||
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel
|
||||
@ -76,6 +76,14 @@ class MCPProviderEntity(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# M2 — user-identity forwarding. When forward_user_identity is True AND
|
||||
# identity_mode is "idp_token", the MCP tool runtime asks dify-enterprise
|
||||
# to mint a fresh SSO id_token for the calling user and stamps it on the
|
||||
# outbound MCP request as `Authorization: Bearer <token>`. Defaults keep
|
||||
# pre-M2 providers unchanged (no forwarding).
|
||||
forward_user_identity: bool = False
|
||||
identity_mode: Literal["off", "idp_token"] = "off"
|
||||
|
||||
@classmethod
|
||||
def from_db_model(cls, db_provider: MCPToolProvider) -> MCPProviderEntity:
|
||||
"""Create entity from database model with decryption"""
|
||||
@ -96,6 +104,8 @@ class MCPProviderEntity(BaseModel):
|
||||
icon=db_provider.icon or "",
|
||||
created_at=db_provider.created_at,
|
||||
updated_at=db_provider.updated_at,
|
||||
forward_user_identity=db_provider.forward_user_identity,
|
||||
identity_mode=db_provider.identity_mode, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
@property
|
||||
@ -170,6 +180,8 @@ class MCPProviderEntity(BaseModel):
|
||||
"updated_at": int(self.updated_at.timestamp()),
|
||||
"label": I18nObject(en_US=self.name, zh_Hans=self.name).to_dict(),
|
||||
"description": I18nObject(en_US="", zh_Hans="").to_dict(),
|
||||
"forward_user_identity": self.forward_user_identity,
|
||||
"identity_mode": self.identity_mode,
|
||||
}
|
||||
|
||||
# Add configuration
|
||||
|
||||
@ -54,36 +54,39 @@ class Blob(BaseModel):
|
||||
|
||||
def as_string(self) -> str:
|
||||
"""Read data as a string."""
|
||||
if self.data is None and self.path:
|
||||
return Path(str(self.path)).read_text(encoding=self.encoding)
|
||||
elif isinstance(self.data, bytes):
|
||||
return self.data.decode(self.encoding)
|
||||
elif isinstance(self.data, str):
|
||||
return self.data
|
||||
else:
|
||||
raise ValueError(f"Unable to get string for blob {self}")
|
||||
match self.data:
|
||||
case None if self.path:
|
||||
return Path(str(self.path)).read_text(encoding=self.encoding)
|
||||
case bytes():
|
||||
return self.data.decode(self.encoding)
|
||||
case str():
|
||||
return self.data
|
||||
case _:
|
||||
raise ValueError(f"Unable to get string for blob {self}")
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
"""Read data as bytes."""
|
||||
if isinstance(self.data, bytes):
|
||||
return self.data
|
||||
elif isinstance(self.data, str):
|
||||
return self.data.encode(self.encoding)
|
||||
elif self.data is None and self.path:
|
||||
return Path(str(self.path)).read_bytes()
|
||||
else:
|
||||
raise ValueError(f"Unable to get bytes for blob {self}")
|
||||
match self.data:
|
||||
case bytes():
|
||||
return self.data
|
||||
case str():
|
||||
return self.data.encode(self.encoding)
|
||||
case None if self.path:
|
||||
return Path(str(self.path)).read_bytes()
|
||||
case _:
|
||||
raise ValueError(f"Unable to get bytes for blob {self}")
|
||||
|
||||
@contextlib.contextmanager
|
||||
def as_bytes_io(self) -> Generator[BytesIO | BufferedReader, None, None]:
|
||||
"""Read data as a byte stream."""
|
||||
if isinstance(self.data, bytes):
|
||||
yield BytesIO(self.data)
|
||||
elif self.data is None and self.path:
|
||||
with open(str(self.path), "rb") as f:
|
||||
yield f
|
||||
else:
|
||||
raise NotImplementedError(f"Unable to convert blob {self}")
|
||||
match self.data:
|
||||
case bytes():
|
||||
yield BytesIO(self.data)
|
||||
case None if self.path:
|
||||
with open(str(self.path), "rb") as f:
|
||||
yield f
|
||||
case _:
|
||||
raise NotImplementedError(f"Unable to convert blob {self}")
|
||||
|
||||
@classmethod
|
||||
def from_path(
|
||||
|
||||
@ -54,6 +54,14 @@ class ToolProviderApiEntity(BaseModel):
|
||||
configuration: MCPConfiguration | None = Field(
|
||||
default=None, description="The timeout and sse_read_timeout of the MCP tool"
|
||||
)
|
||||
# M3 — user-identity forwarding flags. Round-tripped through the console
|
||||
# API so the create/edit modal can hydrate the toggle state.
|
||||
forward_user_identity: bool = Field(
|
||||
default=False, description="Whether Dify forwards the calling user's SSO identity to this MCP server"
|
||||
)
|
||||
identity_mode: str = Field(
|
||||
default="off", description="Identity-forwarding mechanism: 'off' or 'idp_token'"
|
||||
)
|
||||
# Workflow
|
||||
workflow_app_id: str | None = Field(default=None, description="The app id of the workflow tool")
|
||||
|
||||
@ -92,6 +100,10 @@ class ToolProviderApiEntity(BaseModel):
|
||||
optional_fields.update(self.optional_field("is_dynamic_registration", self.is_dynamic_registration))
|
||||
optional_fields.update(self.optional_field("masked_headers", self.masked_headers))
|
||||
optional_fields.update(self.optional_field("original_headers", self.original_headers))
|
||||
# M3 — forwarding flags. Always emit (False/"off" are valid
|
||||
# values that the UI must hydrate, not skip).
|
||||
optional_fields["forward_user_identity"] = self.forward_user_identity
|
||||
optional_fields["identity_mode"] = self.identity_mode
|
||||
case ToolProviderType.WORKFLOW:
|
||||
optional_fields.update(self.optional_field("workflow_app_id", self.workflow_app_id))
|
||||
case _:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from typing import Any, Self
|
||||
from typing import Any, Literal, Self
|
||||
|
||||
from core.entities.mcp_provider import MCPProviderEntity
|
||||
from core.mcp.types import Tool as RemoteMCPTool
|
||||
@ -28,6 +28,8 @@ class MCPToolProviderController(ToolProviderController):
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
sse_read_timeout: float | None = None,
|
||||
forward_user_identity: bool = False,
|
||||
identity_mode: Literal["off", "idp_token"] = "off",
|
||||
):
|
||||
super().__init__(entity)
|
||||
self.entity: ToolProviderEntityWithPlugin = entity
|
||||
@ -37,6 +39,8 @@ class MCPToolProviderController(ToolProviderController):
|
||||
self.headers = headers or {}
|
||||
self.timeout = timeout
|
||||
self.sse_read_timeout = sse_read_timeout
|
||||
self.forward_user_identity = forward_user_identity
|
||||
self.identity_mode: Literal["off", "idp_token"] = identity_mode
|
||||
|
||||
@property
|
||||
def provider_type(self) -> ToolProviderType:
|
||||
@ -105,6 +109,8 @@ class MCPToolProviderController(ToolProviderController):
|
||||
headers=entity.headers,
|
||||
timeout=entity.timeout,
|
||||
sse_read_timeout=entity.sse_read_timeout,
|
||||
forward_user_identity=entity.forward_user_identity,
|
||||
identity_mode=entity.identity_mode,
|
||||
)
|
||||
|
||||
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
|
||||
@ -134,6 +140,8 @@ class MCPToolProviderController(ToolProviderController):
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
sse_read_timeout=self.sse_read_timeout,
|
||||
forward_user_identity=self.forward_user_identity,
|
||||
identity_mode=self.identity_mode,
|
||||
)
|
||||
|
||||
def get_tools(self) -> list[MCPTool]:
|
||||
@ -151,6 +159,8 @@ class MCPToolProviderController(ToolProviderController):
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
sse_read_timeout=self.sse_read_timeout,
|
||||
forward_user_identity=self.forward_user_identity,
|
||||
identity_mode=self.identity_mode,
|
||||
)
|
||||
for tool_entity in self.entity.tools
|
||||
]
|
||||
|
||||
@ -4,7 +4,7 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Generator, Mapping
|
||||
from typing import Any, cast
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from core.mcp.auth_client import MCPClientWithAuthRetry
|
||||
from core.mcp.error import MCPConnectionError
|
||||
@ -38,6 +38,8 @@ class MCPTool(Tool):
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
sse_read_timeout: float | None = None,
|
||||
forward_user_identity: bool = False,
|
||||
identity_mode: Literal["off", "idp_token"] = "off",
|
||||
):
|
||||
super().__init__(entity, runtime)
|
||||
self.tenant_id = tenant_id
|
||||
@ -47,6 +49,8 @@ class MCPTool(Tool):
|
||||
self.headers = headers or {}
|
||||
self.timeout = timeout
|
||||
self.sse_read_timeout = sse_read_timeout
|
||||
self.forward_user_identity = forward_user_identity
|
||||
self.identity_mode: Literal["off", "idp_token"] = identity_mode
|
||||
self._latest_usage = LLMUsage.empty_usage()
|
||||
|
||||
def tool_provider_type(self) -> ToolProviderType:
|
||||
@ -60,7 +64,7 @@ class MCPTool(Tool):
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> Generator[ToolInvokeMessage, None, None]:
|
||||
result = self.invoke_remote_mcp_tool(tool_parameters)
|
||||
result = self.invoke_remote_mcp_tool(tool_parameters, user_id=user_id, app_id=app_id)
|
||||
|
||||
# Extract usage metadata from MCP protocol's _meta field
|
||||
self._latest_usage = self._derive_usage_from_result(result)
|
||||
@ -234,6 +238,8 @@ class MCPTool(Tool):
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
sse_read_timeout=self.sse_read_timeout,
|
||||
forward_user_identity=self.forward_user_identity,
|
||||
identity_mode=self.identity_mode,
|
||||
)
|
||||
|
||||
def _handle_none_parameter(self, parameter: dict[str, Any]) -> dict[str, Any]:
|
||||
@ -246,7 +252,12 @@ class MCPTool(Tool):
|
||||
if value is not None and not (isinstance(value, str) and value.strip() == "")
|
||||
}
|
||||
|
||||
def invoke_remote_mcp_tool(self, tool_parameters: dict[str, Any]) -> CallToolResult:
|
||||
def invoke_remote_mcp_tool(
|
||||
self,
|
||||
tool_parameters: dict[str, Any],
|
||||
user_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
) -> CallToolResult:
|
||||
headers = self.headers.copy() if self.headers else {}
|
||||
tool_parameters = self._handle_none_parameter(tool_parameters)
|
||||
|
||||
@ -271,6 +282,14 @@ class MCPTool(Tool):
|
||||
if tokens and tokens.access_token:
|
||||
headers["Authorization"] = f"{tokens.token_type.capitalize()} {tokens.access_token}"
|
||||
|
||||
# User-identity forwarding: if enabled on this provider, ask the
|
||||
# enterprise side to mint a fresh SSO id_token (audience-scoped to
|
||||
# the MCP server's URL per RFC 8707) and stamp it as Authorization.
|
||||
# This OVERRIDES any Authorization already on the request — the
|
||||
# forwarded identity is what the MCP server should trust.
|
||||
if self.forward_user_identity and self.identity_mode == "idp_token" and user_id:
|
||||
self._inject_forwarded_identity(headers, user_id=user_id, app_id=app_id, audience=server_url)
|
||||
|
||||
# Step 2: Session is now closed, perform network operations without holding database connection
|
||||
# MCPClientWithAuthRetry will create a new session lazily only if auth retry is needed
|
||||
try:
|
||||
@ -286,3 +305,31 @@ class MCPTool(Tool):
|
||||
raise ToolInvokeError(f"Failed to connect to MCP server: {e}") from e
|
||||
except Exception as e:
|
||||
raise ToolInvokeError(f"Failed to invoke tool: {e}") from e
|
||||
|
||||
def _inject_forwarded_identity(
|
||||
self,
|
||||
headers: dict[str, str],
|
||||
*,
|
||||
user_id: str,
|
||||
app_id: str | None,
|
||||
audience: str,
|
||||
) -> None:
|
||||
"""Call the enterprise IssueMCPToken endpoint and stamp Authorization.
|
||||
|
||||
Errors are surfaced as ToolInvokeError so the workflow halts with a
|
||||
clear message instead of silently dropping identity and hitting the
|
||||
MCP server unauthenticated.
|
||||
"""
|
||||
from services.enterprise.base import MCPTokenError
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
|
||||
try:
|
||||
token, _expires_at = EnterpriseService.issue_mcp_token(
|
||||
user_id=user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
app_id=app_id,
|
||||
audience=audience,
|
||||
)
|
||||
except MCPTokenError as e:
|
||||
raise ToolInvokeError(f"Failed to obtain forwarded identity token: {e}") from e
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
"""add identity mode to mcp tool provider
|
||||
|
||||
Revision ID: 3df4dbcc1e21
|
||||
Revises: 7885bd53f9a9
|
||||
Create Date: 2026-05-29 15:00:00.000000
|
||||
|
||||
Adds two columns to `tool_mcp_providers` that drive the M2 MCP user-identity
|
||||
forwarding feature:
|
||||
|
||||
* `forward_user_identity` (bool, default false) — master switch per provider.
|
||||
* `identity_mode` (string, default "off") — which forwarding mechanism to use:
|
||||
"off" — no header forwarded (default; pre-M2 behaviour).
|
||||
"idp_token" — call dify-enterprise /inner/api/mcp/issue-token, stamp
|
||||
the returned id_token on the outbound MCP request as
|
||||
`Authorization: Bearer <token>`.
|
||||
|
||||
The columns are filled with safe defaults for existing rows so older providers
|
||||
keep their current behaviour (no identity forwarding) until an admin opts in.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models as models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "3df4dbcc1e21"
|
||||
down_revision = "7885bd53f9a9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"tool_mcp_providers",
|
||||
sa.Column(
|
||||
"forward_user_identity",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"tool_mcp_providers",
|
||||
sa.Column(
|
||||
"identity_mode",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default=sa.text("'off'"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("tool_mcp_providers", "identity_mode")
|
||||
op.drop_column("tool_mcp_providers", "forward_user_identity")
|
||||
@ -343,6 +343,21 @@ class MCPToolProvider(TypeBase):
|
||||
# encrypted headers for MCP server requests
|
||||
encrypted_headers: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
|
||||
# M2 (MCP user-identity forwarding) — master switch per provider. When True
|
||||
# AND identity_mode is "idp_token", workflows that invoke tools on this
|
||||
# provider will have the caller's SSO id_token stamped on the outbound
|
||||
# request as `Authorization: Bearer …`. Off by default so existing
|
||||
# providers retain pre-M2 behaviour.
|
||||
forward_user_identity: Mapped[bool] = mapped_column(
|
||||
sa.Boolean, nullable=False, server_default=sa.text("false"), default=False
|
||||
)
|
||||
# M2 — which identity-forwarding mechanism to use. Reserved values:
|
||||
# "off" — no forwarding (default).
|
||||
# "idp_token" — forward a Bearer id_token minted by dify-enterprise.
|
||||
identity_mode: Mapped[str] = mapped_column(
|
||||
sa.String(32), nullable=False, server_default=sa.text("'off'"), default="off"
|
||||
)
|
||||
|
||||
def load_user(self) -> Account | None:
|
||||
return db.session.scalar(select(Account).where(Account.id == self.user_id))
|
||||
|
||||
|
||||
@ -2329,15 +2329,15 @@ class DocumentService:
|
||||
# if knowledge_config.data_source:
|
||||
# if knowledge_config.data_source.info_list.data_source_type == "upload_file":
|
||||
# upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
|
||||
# # type: ignore
|
||||
#
|
||||
# count = len(upload_file_list)
|
||||
# elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
|
||||
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list
|
||||
# for notion_info in notion_info_list: # type: ignore
|
||||
# for notion_info in notion_info_list:
|
||||
# count = count + len(notion_info.pages)
|
||||
# elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
|
||||
# website_info = knowledge_config.data_source.info_list.website_info_list
|
||||
# count = len(website_info.urls) # type: ignore
|
||||
# count = len(website_info.urls)
|
||||
# batch_upload_limit = int(dify_config.BATCH_UPLOAD_LIMIT)
|
||||
|
||||
# if features.billing.subscription.plan == CloudPlan.SANDBOX and count > 1:
|
||||
@ -2349,7 +2349,7 @@ class DocumentService:
|
||||
|
||||
# # if dataset is empty, update dataset data_source_type
|
||||
# if not dataset.data_source_type:
|
||||
# dataset.data_source_type = knowledge_config.data_source.info_list.data_source_type # type: ignore
|
||||
# dataset.data_source_type = knowledge_config.data_source.info_list.data_source_type
|
||||
|
||||
# if not dataset.indexing_technique:
|
||||
# if knowledge_config.indexing_technique not in Dataset.INDEXING_TECHNIQUE_LIST:
|
||||
@ -2386,7 +2386,7 @@ class DocumentService:
|
||||
# knowledge_config.retrieval_model.model_dump()
|
||||
# if knowledge_config.retrieval_model
|
||||
# else default_retrieval_model
|
||||
# ) # type: ignore
|
||||
# )
|
||||
|
||||
# documents = []
|
||||
# if knowledge_config.original_document_id:
|
||||
@ -2425,8 +2425,8 @@ class DocumentService:
|
||||
# position = DocumentService.get_documents_position(dataset.id)
|
||||
# document_ids = []
|
||||
# duplicate_document_ids = []
|
||||
# if knowledge_config.data_source.info_list.data_source_type == "upload_file": # type: ignore
|
||||
# upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids # type: ignore
|
||||
# if knowledge_config.data_source.info_list.data_source_type == "upload_file":
|
||||
# upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
|
||||
# for file_id in upload_file_list:
|
||||
# file = (
|
||||
# db.session.query(UploadFile)
|
||||
@ -2452,7 +2452,7 @@ class DocumentService:
|
||||
# name=file_name,
|
||||
# ).first()
|
||||
# if document:
|
||||
# document.dataset_process_rule_id = dataset_process_rule.id # type: ignore
|
||||
# document.dataset_process_rule_id = dataset_process_rule.id
|
||||
# document.updated_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
||||
# document.created_from = created_from
|
||||
# document.doc_form = knowledge_config.doc_form
|
||||
@ -2466,8 +2466,8 @@ class DocumentService:
|
||||
# continue
|
||||
# document = DocumentService.build_document(
|
||||
# dataset,
|
||||
# dataset_process_rule.id, # type: ignore
|
||||
# knowledge_config.data_source.info_list.data_source_type, # type: ignore
|
||||
# dataset_process_rule.id,
|
||||
# knowledge_config.data_source.info_list.data_source_type,
|
||||
# knowledge_config.doc_form,
|
||||
# knowledge_config.doc_language,
|
||||
# data_source_info,
|
||||
@ -2482,8 +2482,8 @@ class DocumentService:
|
||||
# document_ids.append(document.id)
|
||||
# documents.append(document)
|
||||
# position += 1
|
||||
# elif knowledge_config.data_source.info_list.data_source_type == "notion_import": # type: ignore
|
||||
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list # type: ignore
|
||||
# elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
|
||||
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list
|
||||
# if not notion_info_list:
|
||||
# raise ValueError("No notion info list found.")
|
||||
# exist_page_ids = []
|
||||
@ -2523,8 +2523,8 @@ class DocumentService:
|
||||
# truncated_page_name = page.page_name[:255] if page.page_name else "nopagename"
|
||||
# document = DocumentService.build_document(
|
||||
# dataset,
|
||||
# dataset_process_rule.id, # type: ignore
|
||||
# knowledge_config.data_source.info_list.data_source_type, # type: ignore
|
||||
# dataset_process_rule.id,
|
||||
# knowledge_config.data_source.info_list.data_source_type,
|
||||
# knowledge_config.doc_form,
|
||||
# knowledge_config.doc_language,
|
||||
# data_source_info,
|
||||
@ -2544,8 +2544,8 @@ class DocumentService:
|
||||
# # delete not selected documents
|
||||
# if len(exist_document) > 0:
|
||||
# clean_notion_document_task.delay(list(exist_document.values()), dataset.id)
|
||||
# elif knowledge_config.data_source.info_list.data_source_type == "website_crawl": # type: ignore
|
||||
# website_info = knowledge_config.data_source.info_list.website_info_list # type: ignore
|
||||
# elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
|
||||
# website_info = knowledge_config.data_source.info_list.website_info_list
|
||||
# if not website_info:
|
||||
# raise ValueError("No website info list found.")
|
||||
# urls = website_info.urls
|
||||
@ -2563,8 +2563,8 @@ class DocumentService:
|
||||
# document_name = url
|
||||
# document = DocumentService.build_document(
|
||||
# dataset,
|
||||
# dataset_process_rule.id, # type: ignore
|
||||
# knowledge_config.data_source.info_list.data_source_type, # type: ignore
|
||||
# dataset_process_rule.id,
|
||||
# knowledge_config.data_source.info_list.data_source_type,
|
||||
# knowledge_config.doc_form,
|
||||
# knowledge_config.doc_language,
|
||||
# data_source_info,
|
||||
|
||||
@ -12,8 +12,33 @@ from services.errors.enterprise import (
|
||||
EnterpriseAPIForbiddenError,
|
||||
EnterpriseAPINotFoundError,
|
||||
EnterpriseAPIUnauthorizedError,
|
||||
EnterpriseServiceError,
|
||||
)
|
||||
|
||||
|
||||
# M2 — IssueMCPToken specific errors. Co-located here (rather than in
|
||||
# services/errors/enterprise.py) because services.enterprise.base is part of
|
||||
# the leaf-mounted file set the local dev override applies; the errors module
|
||||
# stays at the EE image's baked-in version.
|
||||
class MCPTokenError(EnterpriseServiceError):
|
||||
"""Generic failure of the IssueMCPToken RPC."""
|
||||
|
||||
|
||||
class MCPNoRefreshTokenError(MCPTokenError):
|
||||
"""The user has no stored SSO refresh_token on the enterprise side.
|
||||
The workflow should ask them to re-authenticate."""
|
||||
|
||||
def __init__(self, description: str = ""):
|
||||
super().__init__(description, status_code=428)
|
||||
|
||||
|
||||
class MCPIdentityRefreshError(MCPTokenError):
|
||||
"""The enterprise side tried to refresh the user's SSO refresh_token
|
||||
against the IdP and failed (revoked/expired/IdP error)."""
|
||||
|
||||
def __init__(self, description: str = ""):
|
||||
super().__init__(description, status_code=401)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@ -11,7 +11,16 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from configs import dify_config
|
||||
from extensions.ext_redis import redis_client
|
||||
from services.enterprise.base import EnterpriseRequest
|
||||
from services.enterprise.base import (
|
||||
EnterpriseRequest,
|
||||
MCPIdentityRefreshError,
|
||||
MCPNoRefreshTokenError,
|
||||
MCPTokenError,
|
||||
)
|
||||
from services.errors.enterprise import (
|
||||
EnterpriseAPIError,
|
||||
EnterpriseAPIUnauthorizedError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from services.feature_service import LicenseStatus
|
||||
@ -121,6 +130,62 @@ class EnterpriseService:
|
||||
def get_workspace_info(cls, tenant_id: str):
|
||||
return EnterpriseRequest.send_request("GET", f"/workspace/{tenant_id}/info")
|
||||
|
||||
@classmethod
|
||||
def issue_mcp_token(
|
||||
cls,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
app_id: str | None,
|
||||
audience: str,
|
||||
) -> tuple[str, int]:
|
||||
"""Mint a short-lived SSO id_token (or OAuth2 access_token) representing
|
||||
the calling Dify user, audience-scoped to the given MCP server identifier.
|
||||
|
||||
Used by MCPTool.invoke_remote_mcp_tool to stamp `Authorization: Bearer
|
||||
<token>` on outbound MCP requests when the provider has
|
||||
forward_user_identity=True and identity_mode="idp_token".
|
||||
|
||||
Returns:
|
||||
(token, expires_at_unix_seconds)
|
||||
|
||||
Raises:
|
||||
MCPNoRefreshTokenError: user has no stored SSO refresh_token on the
|
||||
enterprise side; surface to the workflow as "please log in via SSO".
|
||||
MCPIdentityRefreshError: enterprise tried to refresh against the IdP
|
||||
and the IdP rejected (revoked/expired session).
|
||||
MCPTokenError: any other failure of the enterprise endpoint.
|
||||
"""
|
||||
try:
|
||||
response = EnterpriseRequest.send_request(
|
||||
"POST",
|
||||
"/mcp/issue-token",
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"tenant_id": tenant_id,
|
||||
"app_id": app_id or "",
|
||||
"audience": audience,
|
||||
},
|
||||
)
|
||||
except EnterpriseAPIUnauthorizedError as e:
|
||||
# Enterprise side returns 401 when the IdP rejected the refresh.
|
||||
raise MCPIdentityRefreshError(str(e) or "identity refresh failed; please re-authenticate") from e
|
||||
except EnterpriseAPIError as e:
|
||||
# Map the 428 PreconditionRequired we emit on no-stored-refresh-token.
|
||||
if getattr(e, "status_code", None) == 428:
|
||||
raise MCPNoRefreshTokenError(
|
||||
str(e) or "user has no stored SSO refresh token; please re-authenticate"
|
||||
) from e
|
||||
raise MCPTokenError(f"issue_mcp_token failed: {e}") from e
|
||||
|
||||
if not isinstance(response, dict):
|
||||
raise MCPTokenError("invalid response shape from enterprise /mcp/issue-token")
|
||||
|
||||
token = response.get("token")
|
||||
expires_at = response.get("expires_at")
|
||||
if not token or not isinstance(token, str) or not isinstance(expires_at, int):
|
||||
raise MCPTokenError(f"missing token/expires_at in enterprise response: {response}")
|
||||
return token, expires_at
|
||||
|
||||
@classmethod
|
||||
def initiate_device_flow_sso(cls, signed_state: str) -> dict:
|
||||
return EnterpriseRequest.send_request(
|
||||
|
||||
@ -4,7 +4,7 @@ import logging
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@ -136,6 +136,8 @@ class MCPToolManageService:
|
||||
configuration: MCPConfiguration,
|
||||
authentication: MCPAuthentication | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
forward_user_identity: bool = False,
|
||||
identity_mode: Literal["off", "idp_token"] = "off",
|
||||
) -> ToolProviderApiEntity:
|
||||
"""Create a new MCP provider."""
|
||||
# Validate URL format
|
||||
@ -171,6 +173,8 @@ class MCPToolManageService:
|
||||
sse_read_timeout=configuration.sse_read_timeout,
|
||||
encrypted_headers=encrypted_headers,
|
||||
encrypted_credentials=encrypted_credentials,
|
||||
forward_user_identity=forward_user_identity,
|
||||
identity_mode=identity_mode,
|
||||
)
|
||||
|
||||
self._session.add(mcp_tool)
|
||||
@ -194,6 +198,8 @@ class MCPToolManageService:
|
||||
configuration: MCPConfiguration,
|
||||
authentication: MCPAuthentication | None = None,
|
||||
validation_result: ServerUrlValidationResult | None = None,
|
||||
forward_user_identity: bool | None = None,
|
||||
identity_mode: Literal["off", "idp_token"] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Update an MCP provider.
|
||||
@ -255,6 +261,14 @@ class MCPToolManageService:
|
||||
if authentication and authentication.client_id:
|
||||
mcp_provider.encrypted_credentials = self._process_credentials(authentication, mcp_provider, tenant_id)
|
||||
|
||||
# Update user-identity forwarding settings if provided.
|
||||
# None means "leave unchanged" so this stays backwards-compatible
|
||||
# with existing callers that don't know about M2.
|
||||
if forward_user_identity is not None:
|
||||
mcp_provider.forward_user_identity = forward_user_identity
|
||||
if identity_mode is not None:
|
||||
mcp_provider.identity_mode = identity_mode
|
||||
|
||||
# Flush changes to database
|
||||
self._session.flush()
|
||||
|
||||
|
||||
@ -7,9 +7,13 @@ When('I open the publish panel', async function (this: DifyWorld) {
|
||||
})
|
||||
|
||||
When('I publish the app', async function (this: DifyWorld) {
|
||||
await this.getPage().getByRole('button', { name: /Publish Update/ }).click()
|
||||
await this.getPage()
|
||||
.getByRole('button', { name: /Publish Update/ })
|
||||
.click()
|
||||
})
|
||||
|
||||
Then('the app should be marked as published', async function (this: DifyWorld) {
|
||||
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { createTestApp, enableAppSiteAndGetURL, publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api'
|
||||
import {
|
||||
createTestApp,
|
||||
enableAppSiteAndGetURL,
|
||||
publishWorkflowApp,
|
||||
syncRunnableWorkflowDraft,
|
||||
} from '../../../support/api'
|
||||
|
||||
When('I enable the Web App share', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName)
|
||||
throw new Error('No app name available. Run "a \\"workflow\\" app has been created via API" first.')
|
||||
if (!appName) {
|
||||
throw new Error(
|
||||
'No app name available. Run "a \\"workflow\\" app has been created via API" first.',
|
||||
)
|
||||
}
|
||||
|
||||
await page.locator('button').filter({ hasText: appName }).filter({ hasText: 'Workflow' }).click()
|
||||
await expect(page.getByRole('switch').first()).toBeEnabled({ timeout: 15_000 })
|
||||
@ -28,8 +36,11 @@ Given('a workflow app has been published and shared via API', async function (th
|
||||
})
|
||||
|
||||
When('I open the shared app URL', async function (this: DifyWorld) {
|
||||
if (!this.shareURL)
|
||||
throw new Error('No share URL available. Run "a workflow app has been published and shared via API" first.')
|
||||
if (!this.shareURL) {
|
||||
throw new Error(
|
||||
'No share URL available. Run "a workflow app has been published and shared via API" first.',
|
||||
)
|
||||
}
|
||||
await this.getPage().goto(this.shareURL, { timeout: 20_000 })
|
||||
})
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ Given('a minimal runnable workflow draft has been synced', async function (this:
|
||||
|
||||
When('I run the workflow', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const testRunButton = page.getByText('Test Run')
|
||||
const testRunButton = page.getByRole('button', { name: /Test Run/ })
|
||||
|
||||
await expect(testRunButton).toBeVisible({ timeout: 15_000 })
|
||||
await testRunButton.click()
|
||||
@ -20,6 +20,6 @@ When('I run the workflow', async function (this: DifyWorld) {
|
||||
|
||||
Then('the workflow run should succeed', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await page.getByText('DETAIL').click()
|
||||
await expect(page.getByText('SUCCESS').first()).toBeVisible({ timeout: 55_000 })
|
||||
await page.getByText('DETAIL', { exact: true }).click()
|
||||
await expect(page.getByText('SUCCESS', { exact: true }).first()).toBeVisible({ timeout: 55_000 })
|
||||
})
|
||||
|
||||
@ -147,11 +147,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/snippets/[snippetId]/page.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(humanInputLayout)/form/[token]/form.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -248,11 +243,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/nav-link/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -3172,16 +3162,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/snippets/hooks/use-nodes-sync-draft.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/snippets/hooks/use-snippet-run.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -3352,11 +3332,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/blocks.tsx": {
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/hooks.ts": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -5240,11 +5215,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/__tests__/use-snippet-workflows.spec.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
@ -5510,11 +5480,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/service/use-snippet-workflows.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-tools.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 2.25C3.41421 2.25 3.75 2.58579 3.75 3V15C3.75 15.4142 3.41421 15.75 3 15.75C2.58579 15.75 2.25 15.4142 2.25 15V3C2.25 2.58579 2.58579 2.25 3 2.25Z" fill="#676F83"/>
|
||||
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3V15C15.75 15.4142 15.4142 15.75 15 15.75C14.5858 15.75 14.25 15.4142 14.25 15V3C14.25 2.58579 14.5858 2.25 15 2.25Z" fill="#676F83"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 4.5C10.5392 4.5 10.875 4.83579 10.875 5.25V12.75C10.875 13.1642 10.5392 13.5 10.125 13.5H7.875C7.46079 13.5 7.125 13.1642 7.125 12.75V5.25C7.125 4.83579 7.46079 4.5 7.875 4.5H10.125ZM8.625 12H9.375V6H8.625V12Z" fill="#676F83"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 751 B |
@ -1,5 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 14.25C15.4142 14.25 15.75 14.5858 15.75 15C15.75 15.4142 15.4142 15.75 15 15.75H3C2.58579 15.75 2.25 15.4142 2.25 15C2.25 14.5858 2.58579 14.25 3 14.25H15Z" fill="#676F83"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 7.125C13.9142 7.125 14.25 7.46079 14.25 7.875V10.125C14.25 10.5392 13.9142 10.875 13.5 10.875H4.5C4.08579 10.875 3.75 10.5392 3.75 10.125V7.875C3.75 7.46079 4.08579 7.125 4.5 7.125H13.5ZM5.25 9.375H12.75V8.625H5.25V9.375Z" fill="#676F83"/>
|
||||
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3C15.75 3.41421 15.4142 3.75 15 3.75H3C2.58579 3.75 2.25 3.41421 2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H15Z" fill="#676F83"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 763 B |
@ -1,3 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 15V3.75V12.2625V10.6688V15ZM2.5 16.5C1.94772 16.5 1.5 16.0523 1.5 15.5V3.25C1.5 2.69771 1.94772 2.25 2.5 2.25H15.5C16.0523 2.25 16.5 2.69772 16.5 3.25V10.5H15V3.75H3V15H9V16.5H2.5ZM13.0125 17.25L10.35 14.5875L11.4188 13.5375L13.0125 15.1312L16.2 11.9438L17.25 13.0125L13.0125 17.25ZM7.5 9.75H13.5V8.25H7.5V9.75ZM7.5 6.75H13.5V5.25H7.5V6.75ZM4.5 9.75H6V8.25H4.5V9.75ZM4.5 6.75H6V5.25H4.5V6.75Z" fill="#495464"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 526 B |
@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 563 B |
@ -513,27 +513,12 @@
|
||||
"width": 14,
|
||||
"height": 14
|
||||
},
|
||||
"line-others-dhs": {
|
||||
"body": "<g fill=\"currentColor\"><path d=\"M3 2.25a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3A.75.75 0 0 1 3 2.25m12 0a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75\"/><path fill-rule=\"evenodd\" d=\"M10.125 4.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75zm-1.5 7.5h.75V6h-.75z\" clip-rule=\"evenodd\"/></g>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-drag-handle": {
|
||||
"body": "<g fill=\"none\"><g id=\"Drag Handle\"><path id=\"drag-handle\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 5C6.55228 5 7 4.55228 7 4C7 3.44772 6.55228 3 6 3C5.44772 3 5 3.44772 5 4C5 4.55228 5.44772 5 6 5ZM6 9C6.55228 9 7 8.55228 7 8C7 7.44772 6.55228 7 6 7C5.44772 7 5 7.44772 5 8C5 8.55228 5.44772 9 6 9ZM11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM10 9C10.5523 9 11 8.55228 11 8C11 7.44772 10.5523 7 10 7C9.44772 7 9 7.44772 9 8C9 8.55228 9.44772 9 10 9ZM7 12C7 12.5523 6.55228 13 6 13C5.44772 13 5 12.5523 5 12C5 11.4477 5.44772 11 6 11C6.55228 11 7 11.4477 7 12ZM10 13C10.5523 13 11 12.5523 11 12C11 11.4477 10.5523 11 10 11C9.44772 11 9 11.4477 9 12C9 12.5523 9.44772 13 10 13Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
"line-others-dvs": {
|
||||
"body": "<g fill=\"currentColor\"><path d=\"M15 14.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/><path fill-rule=\"evenodd\" d=\"M13.5 7.125a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.75.75h-9a.75.75 0 0 1-.75-.75v-2.25a.75.75 0 0 1 .75-.75zm-8.25 2.25h7.5v-.75h-7.5z\" clip-rule=\"evenodd\"/><path d=\"M15 2.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/></g>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-env": {
|
||||
"body": "<g fill=\"none\"><g id=\"env\"><g id=\"Vector\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 3.33325C1.33325 2.22868 2.22868 1.33325 3.33325 1.33325H12.6666C13.7712 1.33325 14.6666 2.22869 14.6666 3.33325V3.66659C14.6666 4.03478 14.3681 4.33325 13.9999 4.33325C13.6317 4.33325 13.3333 4.03478 13.3333 3.66659V3.33325C13.3333 2.96506 13.0348 2.66659 12.6666 2.66659H3.33325C2.96506 2.66659 2.66659 2.96506 2.66659 3.33325V3.66659C2.66659 4.03478 2.36811 4.33325 1.99992 4.33325C1.63173 4.33325 1.33325 4.03478 1.33325 3.66659V3.33325Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M14.6666 12.6666C14.6666 13.7712 13.7712 14.6666 12.6666 14.6666L3.33325 14.6666C2.22866 14.6666 1.33325 13.7711 1.33325 12.6666L1.33325 12.3333C1.33325 11.9651 1.63173 11.6666 1.99992 11.6666C2.36811 11.6666 2.66659 11.9651 2.66659 12.3333V12.6666C2.66659 13.0348 2.96505 13.3333 3.33325 13.3333L12.6666 13.3333C13.0348 13.3333 13.3333 13.0348 13.3333 12.6666V12.3333C13.3333 11.9651 13.6317 11.6666 13.9999 11.6666C14.3681 11.6666 14.6666 11.9651 14.6666 12.3333V12.6666Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 5.99992C1.33325 5.63173 1.63173 5.33325 1.99992 5.33325H4.33325C4.70144 5.33325 4.99992 5.63173 4.99992 5.99992C4.99992 6.36811 4.70144 6.66658 4.33325 6.66658H2.66659V7.33325H3.99992C4.36811 7.33325 4.66659 7.63173 4.66659 7.99992C4.66659 8.36811 4.36811 8.66658 3.99992 8.66658H2.66659V9.33325H4.33325C4.70144 9.33325 4.99992 9.63173 4.99992 9.99992C4.99992 10.3681 4.70144 10.6666 4.33325 10.6666H1.99992C1.63173 10.6666 1.33325 10.3681 1.33325 9.99992V5.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.4734 5.36186C6.75457 5.27673 7.05833 5.38568 7.22129 5.63012L8.66659 7.79807V5.99992C8.66659 5.63173 8.96506 5.33325 9.33325 5.33325C9.70144 5.33325 9.99992 5.63173 9.99992 5.99992V9.99992C9.99992 10.2937 9.80761 10.5528 9.52644 10.638C9.24527 10.7231 8.94151 10.6142 8.77855 10.3697L7.33325 8.20177V9.99992C7.33325 10.3681 7.03478 10.6666 6.66659 10.6666C6.2984 10.6666 5.99992 10.3681 5.99992 9.99992V5.99992C5.99992 5.70614 6.19222 5.44699 6.4734 5.36186Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M11.0768 5.38453C11.4167 5.24292 11.807 5.40364 11.9486 5.74351L12.9999 8.26658L14.0512 5.74351C14.1928 5.40364 14.5831 5.24292 14.923 5.38453C15.2629 5.52614 15.4236 5.91646 15.282 6.25633L13.6153 10.2563C13.5118 10.5048 13.2691 10.6666 12.9999 10.6666C12.7308 10.6666 12.488 10.5048 12.3845 10.2563L10.7179 6.25633C10.5763 5.91646 10.737 5.52614 11.0768 5.38453Z\" fill=\"currentColor\"/></g></g></g>"
|
||||
},
|
||||
"line-others-evaluation": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M3 15V3.75v8.513v-1.594zm-.5 1.5a1 1 0 0 1-1-1V3.25a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v7.25H15V3.75H3V15h6v1.5zm10.513.75l-2.663-2.662l1.069-1.05l1.593 1.593l3.188-3.187l1.05 1.068zM7.5 9.75h6v-1.5h-6zm0-3h6v-1.5h-6zm-3 3H6v-1.5H4.5zm0-3H6v-1.5H4.5z\"/>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-global-variable": {
|
||||
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.23814 1.33333H9.76188C10.4844 1.33332 11.0672 1.33332 11.5391 1.37187C12.025 1.41157 12.4518 1.49545 12.8466 1.69664C13.4739 2.01622 13.9838 2.52615 14.3034 3.15336C14.5046 3.54822 14.5884 3.97501 14.6281 4.46091C14.6667 4.93283 14.6667 5.51559 14.6667 6.23811V9.76188C14.6667 10.4844 14.6667 11.0672 14.6281 11.5391C14.5884 12.025 14.5046 12.4518 14.3034 12.8466C13.9838 13.4738 13.4739 13.9838 12.8466 14.3033C12.4518 14.5045 12.025 14.5884 11.5391 14.6281C11.0672 14.6667 10.4844 14.6667 9.7619 14.6667H6.23812C5.51561 14.6667 4.93284 14.6667 4.46093 14.6281C3.97503 14.5884 3.54824 14.5045 3.15338 14.3033C2.52617 13.9838 2.01623 13.4738 1.69666 12.8466C1.49546 12.4518 1.41159 12.025 1.37189 11.5391C1.33333 11.0672 1.33334 10.4844 1.33334 9.76187V6.23812C1.33334 5.5156 1.33333 4.93283 1.37189 4.46091C1.41159 3.97501 1.49546 3.54822 1.69666 3.15336C2.01623 2.52615 2.52617 2.01622 3.15338 1.69664C3.54824 1.49545 3.97503 1.41157 4.46093 1.37187C4.93285 1.33332 5.51561 1.33332 6.23814 1.33333ZM4.5695 2.70078C4.16606 2.73374 3.93427 2.79519 3.7587 2.88465C3.38237 3.0764 3.07641 3.38236 2.88466 3.75868C2.79521 3.93425 2.73376 4.16604 2.70079 4.56949C2.6672 4.98072 2.66668 5.50892 2.66668 6.26666V9.73333C2.66668 10.4911 2.6672 11.0193 2.70079 11.4305C2.73376 11.8339 2.79521 12.0657 2.88466 12.2413C3.07641 12.6176 3.38237 12.9236 3.7587 13.1153C3.93427 13.2048 4.16606 13.2662 4.5695 13.2992C4.98073 13.3328 5.50894 13.3333 6.26668 13.3333H9.73334C10.4911 13.3333 11.0193 13.3328 11.4305 13.2992C11.834 13.2662 12.0658 13.2048 12.2413 13.1153C12.6176 12.9236 12.9236 12.6176 13.1154 12.2413C13.2048 12.0657 13.2663 11.8339 13.2992 11.4305C13.3328 11.0193 13.3333 10.4911 13.3333 9.73333V6.26666C13.3333 5.50892 13.3328 4.98072 13.2992 4.56949C13.2663 4.16604 13.2048 3.93425 13.1154 3.75868C12.9236 3.38236 12.6176 3.0764 12.2413 2.88465C12.0658 2.79519 11.834 2.73374 11.4305 2.70078C11.0193 2.66718 10.4911 2.66666 9.73334 2.66666H6.26668C5.50894 2.66666 4.98073 2.66718 4.5695 2.70078ZM5.08339 5.33333C5.08339 4.96514 5.38187 4.66666 5.75006 4.66666H6.68433C7.324 4.66666 7.87606 5.09677 8.04724 5.70542L8.30138 6.60902L9.2915 5.43554C9.7018 4.94926 10.3035 4.66666 10.9399 4.66666H11C11.3682 4.66666 11.6667 4.96514 11.6667 5.33333C11.6667 5.70152 11.3682 5.99999 11 5.99999H10.9399C10.7005 5.99999 10.4702 6.10616 10.3106 6.29537L8.73751 8.15972L9.23641 9.93357C9.24921 9.97909 9.28574 10 9.31579 10H10.2501C10.6182 10 10.9167 10.2985 10.9167 10.6667C10.9167 11.0349 10.6182 11.3333 10.2501 11.3333H9.31579C8.67612 11.3333 8.12406 10.9032 7.95288 10.2946L7.69871 9.39088L6.70852 10.5644C6.29822 11.0507 5.6965 11.3333 5.06011 11.3333H5.00001C4.63182 11.3333 4.33334 11.0349 4.33334 10.6667C4.33334 10.2985 4.63182 10 5.00001 10H5.06011C5.29949 10 5.52982 9.89383 5.68946 9.70462L7.26258 7.84019L6.76371 6.06642C6.75091 6.0209 6.71438 5.99999 6.68433 5.99999H5.75006C5.38187 5.99999 5.08339 5.70152 5.08339 5.33333Z\" fill=\"currentColor\"/></g>"
|
||||
},
|
||||
@ -1040,11 +1025,6 @@
|
||||
"workflow-if-else": {
|
||||
"body": "<g fill=\"none\"><g id=\"icons/if-else\"><path id=\"Vector (Stroke)\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.16667 2.98975C7.80423 2.98975 7.51042 2.69593 7.51042 2.3335C7.51042 1.97106 7.80423 1.67725 8.16667 1.67725H11.0833C11.4458 1.67725 11.7396 1.97106 11.7396 2.3335V5.25016C11.7396 5.6126 11.4458 5.90641 11.0833 5.90641C10.7209 5.90641 10.4271 5.6126 10.4271 5.25016V3.91782L7.34474 7.00016L10.4271 10.0825V8.75016C10.4271 8.38773 10.7209 8.09391 11.0833 8.09391C11.4458 8.09391 11.7396 8.38773 11.7396 8.75016V11.6668C11.7396 12.0293 11.4458 12.3231 11.0833 12.3231H8.16667C7.80423 12.3231 7.51042 12.0293 7.51042 11.6668C7.51042 11.3044 7.80423 11.0106 8.16667 11.0106H9.49901L6.14484 7.65641H1.75C1.38756 7.65641 1.09375 7.3626 1.09375 7.00016C1.09375 6.63773 1.38756 6.34391 1.75 6.34391H6.14484L9.49901 2.98975H8.16667Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
"workflow-input-field": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M5.333 3.333h2v9.334h-2V14h5.333v-1.333h-2V3.333h2V2H5.333zm-4 1.334a.667.667 0 0 0-.666.666v5.334c0 .368.298.666.666.666h4V10H2V6h3.333V4.667zM10.667 6H14v4h-3.334v1.333h4a.667.667 0 0 0 .667-.666V5.333a.667.667 0 0 0-.667-.666h-4z\"/>",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
},
|
||||
"workflow-iteration": {
|
||||
"body": "<g fill=\"none\"><g id=\"icons/iteration\"><path id=\"Vector\" d=\"M6.82849 0.754349C6.6007 0.526545 6.23133 0.526545 6.00354 0.754349C5.77573 0.982158 5.77573 1.3515 6.00354 1.57931L6.82849 0.754349ZM8.16602 2.91683L8.57849 3.32931C8.80628 3.1015 8.80628 2.73216 8.57849 2.50435L8.16602 2.91683ZM6.00354 4.25435C5.77573 4.48216 5.77573 4.8515 6.00354 5.07931C6.23133 5.30711 6.6007 5.30711 6.82849 5.07931L6.00354 4.25435ZM7.99516 9.74597C8.22295 9.51818 8.22295 9.14881 7.99516 8.92102C7.76737 8.69323 7.398 8.69323 7.17021 8.92102L7.99516 9.74597ZM5.83268 11.0835L5.4202 10.671C5.1924 10.8988 5.1924 11.2682 5.4202 11.496L5.83268 11.0835ZM7.17021 13.246C7.398 13.4738 7.76737 13.4738 7.99516 13.246C8.22295 13.0182 8.22295 12.6488 7.99516 12.421L7.17021 13.246ZM11.4993 3.73414C11.2738 3.50404 10.9045 3.5003 10.6744 3.72578C10.4443 3.95127 10.4405 4.32059 10.6661 4.55069L11.4993 3.73414ZM7.58268 3.50016C7.90486 3.50016 8.16602 3.23899 8.16602 2.91683C8.16602 2.59467 7.90486 2.3335 7.58268 2.3335L7.58268 3.50016ZM2.49938 10.2662C2.72486 10.4963 3.09419 10.5 3.32429 10.2745C3.55439 10.0491 3.55814 9.6797 3.33266 9.44964L2.49938 10.2662ZM6.00354 1.57931L7.75354 3.32931L8.57849 2.50435L6.82849 0.754349L6.00354 1.57931ZM7.75354 2.50435L6.00354 4.25435L6.82849 5.07931L8.57849 3.32931L7.75354 2.50435ZM7.17021 8.92102L5.4202 10.671L6.24516 11.496L7.99516 9.74597L7.17021 8.92102ZM5.4202 11.496L7.17021 13.246L7.99516 12.421L6.24516 10.671L5.4202 11.496ZM8.16602 10.5002L6.41602 10.5002V11.6668L8.16602 11.6668V10.5002ZM11.666 7.00016C11.666 8.93316 10.099 10.5002 8.16602 10.5002V11.6668C10.7434 11.6668 12.8327 9.57751 12.8327 7.00016H11.666ZM12.8327 7.00016C12.8327 5.72882 12.3235 4.57524 11.4993 3.73414L10.6661 4.55069C11.2852 5.18256 11.666 6.0463 11.666 7.00016H12.8327ZM5.83268 3.50016H7.58268L7.58268 2.3335H5.83268L5.83268 3.50016ZM2.33268 7.00016C2.33268 5.06717 3.89968 3.50016 5.83268 3.50016L5.83268 2.3335C3.25535 2.3335 1.16602 4.42283 1.16602 7.00016H2.33268ZM1.16602 7.00016C1.16602 8.27148 1.67517 9.42508 2.49938 10.2662L3.33266 9.44964C2.71348 8.81777 2.33268 7.95403 2.33268 7.00016H1.16602Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 281,
|
||||
"total": 277,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -510,6 +510,9 @@ catalogs:
|
||||
scheduler:
|
||||
specifier: 0.27.0
|
||||
version: 0.27.0
|
||||
server-only:
|
||||
specifier: 0.0.1
|
||||
version: 0.0.1
|
||||
sharp:
|
||||
specifier: 0.34.5
|
||||
version: 0.34.5
|
||||
@ -1242,6 +1245,9 @@ importers:
|
||||
scheduler:
|
||||
specifier: 'catalog:'
|
||||
version: 0.27.0
|
||||
server-only:
|
||||
specifier: 'catalog:'
|
||||
version: 0.0.1
|
||||
sharp:
|
||||
specifier: 'catalog:'
|
||||
version: 0.34.5
|
||||
@ -8413,6 +8419,9 @@ packages:
|
||||
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
@ -16641,6 +16650,8 @@ snapshots:
|
||||
|
||||
seroval@1.5.1: {}
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
@ -17758,6 +17769,7 @@ time:
|
||||
remark-breaks@4.0.0: '2023-09-22T16:45:41.061Z'
|
||||
remark-directive@4.0.0: '2025-02-27T15:15:20.630Z'
|
||||
scheduler@0.27.0: '2025-10-01T21:39:15.208Z'
|
||||
server-only@0.0.1: '2022-09-03T01:07:26.139Z'
|
||||
sharp@0.34.5: '2025-11-06T14:19:40.989Z'
|
||||
shiki@4.1.0: '2026-05-19T07:51:34.358Z'
|
||||
socket.io-client@4.8.3: '2025-12-23T16:39:16.428Z'
|
||||
|
||||
@ -213,6 +213,7 @@ catalog:
|
||||
remark-breaks: 4.0.0
|
||||
remark-directive: 4.0.0
|
||||
scheduler: 0.27.0
|
||||
server-only: 0.0.1
|
||||
sharp: 0.34.5
|
||||
shiki: 4.1.0
|
||||
socket.io-client: 4.8.3
|
||||
|
||||
@ -4,6 +4,9 @@ NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT
|
||||
NEXT_PUBLIC_EDITION=SELF_HOSTED
|
||||
# The base path for the application
|
||||
NEXT_PUBLIC_BASE_PATH=
|
||||
# Server-only console API origin for server-side requests.
|
||||
# Usually matches CONSOLE_API_URL from Docker deployment; local dev can rely on NEXT_PUBLIC_API_PREFIX fallback.
|
||||
CONSOLE_API_URL=http://localhost:5001
|
||||
# The base URL of console application, refers to the Console base URL of WEB service if console domain is
|
||||
# different from api or web app domain.
|
||||
# example: https://cloud.dify.ai/console/api
|
||||
|
||||
@ -341,11 +341,16 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
// -- Tab navigation --
|
||||
describe('Tab Navigation', () => {
|
||||
it('should render the app type dropdown trigger', () => {
|
||||
it('should render all category tabs', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -376,19 +381,21 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
// -- "Created by me" filter --
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should not render a standalone "created by me" checkbox in the current header layout', () => {
|
||||
it('should render the "created by me" checkbox', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the current layout stable without a "created by me" control', () => {
|
||||
it('should toggle the "created by me" filter on click', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
|
||||
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -141,7 +141,6 @@ describe('Header Account Dropdown Flow', () => {
|
||||
})
|
||||
|
||||
it('logs out, resets cached user markers, and redirects to signin', async () => {
|
||||
localStorage.setItem('setup_status', 'done')
|
||||
localStorage.setItem('education-reverify-prev-expire-at', '1')
|
||||
localStorage.setItem('education-reverify-has-noticed', '1')
|
||||
localStorage.setItem('education-expired-has-noticed', '1')
|
||||
@ -157,7 +156,6 @@ describe('Header Account Dropdown Flow', () => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/signin')
|
||||
})
|
||||
|
||||
expect(localStorage.getItem('setup_status')).toBeNull()
|
||||
expect(localStorage.getItem('education-reverify-prev-expire-at')).toBeNull()
|
||||
expect(localStorage.getItem('education-reverify-has-noticed')).toBeNull()
|
||||
expect(localStorage.getItem('education-expired-has-noticed')).toBeNull()
|
||||
|
||||
124
web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx
Normal file
124
web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queryClient: undefined as QueryClient | undefined,
|
||||
profileQueryFn: vi.fn(),
|
||||
systemFeaturesQueryFn: vi.fn(),
|
||||
redirect: vi.fn((url: string) => {
|
||||
throw new Error(`NEXT_REDIRECT:${url}`)
|
||||
}),
|
||||
headers: vi.fn(),
|
||||
resolveServerConsoleApiUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/query-client-server', () => ({
|
||||
getQueryClientServer: () => mocks.queryClient,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/headers', () => ({
|
||||
headers: () => mocks.headers(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
redirect: (url: string) => mocks.redirect(url),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/account-profile/server', () => ({
|
||||
resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args),
|
||||
serverUserProfileQueryOptions: () => ({
|
||||
queryKey: ['common', 'user-profile'],
|
||||
queryFn: mocks.profileQueryFn,
|
||||
retry: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/system-features', () => ({
|
||||
systemFeaturesQueryOptions: () => ({
|
||||
queryKey: ['console', 'system-features'],
|
||||
queryFn: mocks.systemFeaturesQueryFn,
|
||||
retry: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('CommonLayoutHydrationBoundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
mocks.headers.mockResolvedValue(new Headers({
|
||||
'x-dify-pathname': '/apps',
|
||||
'x-dify-search': '?tag=workflow',
|
||||
}))
|
||||
mocks.resolveServerConsoleApiUrl.mockReturnValue('https://console.example.com/console/api/account/profile')
|
||||
mocks.profileQueryFn.mockResolvedValue({
|
||||
profile: {
|
||||
id: 'account-id',
|
||||
name: 'Dify User',
|
||||
email: 'user@example.com',
|
||||
avatar: '',
|
||||
avatar_url: null,
|
||||
is_password_set: true,
|
||||
},
|
||||
meta: {
|
||||
currentVersion: '1.0.0',
|
||||
currentEnv: 'DEVELOPMENT',
|
||||
},
|
||||
})
|
||||
mocks.systemFeaturesQueryFn.mockResolvedValue({ branding: { enabled: false } })
|
||||
})
|
||||
|
||||
it('should hydrate common layout queries and render children', async () => {
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
const element = await CommonLayoutHydrationBoundary({
|
||||
children: <div>Common shell</div>,
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{element as ReactElement}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByText('Common shell')).toBeInTheDocument()
|
||||
expect(mocks.profileQueryFn).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.systemFeaturesQueryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should redirect unauthorized users to the refresh route with the current path', async () => {
|
||||
mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'unauthorized' }), { status: 401 }))
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT')
|
||||
|
||||
expect(mocks.redirect).toHaveBeenCalledWith('/auth/refresh?redirect_url=%2Fapps%3Ftag%3Dworkflow')
|
||||
})
|
||||
|
||||
it('should redirect setup errors to install', async () => {
|
||||
mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'not_setup' }), { status: 401 }))
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT')
|
||||
|
||||
expect(mocks.redirect).toHaveBeenCalledWith('/install')
|
||||
})
|
||||
|
||||
it('should render children without server prefetch when the server API URL is not resolvable', async () => {
|
||||
mocks.resolveServerConsoleApiUrl.mockReturnValue(null)
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
const element = await CommonLayoutHydrationBoundary({
|
||||
children: <div>Common shell</div>,
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{element as ReactElement}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByText('Common shell')).toBeInTheDocument()
|
||||
expect(mocks.profileQueryFn).not.toHaveBeenCalled()
|
||||
expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -25,6 +25,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
|
||||
import { getLocalStorageItem, useLocalStorageBoolean } from '@/utils/local-storage'
|
||||
|
||||
type IAppDetailLayoutProps = {
|
||||
children: React.ReactNode
|
||||
@ -54,13 +55,14 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const pathname = usePathname()
|
||||
const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
|
||||
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
|
||||
const storedHideHeader = useLocalStorageBoolean('workflow-canvas-maximize')
|
||||
const [eventHideHeader, setEventHideHeader] = useState<boolean | null>(null)
|
||||
const hideHeader = eventHideHeader ?? storedHideHeader
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === 'workflow-canvas-maximize')
|
||||
setHideHeader(v.payload)
|
||||
setEventHideHeader(v.payload)
|
||||
})
|
||||
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
|
||||
@ -125,7 +127,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand)
|
||||
|
||||
useEffect(() => {
|
||||
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
|
||||
const localeMode = getLocalStorageItem('app-detail-collapse-or-expand', 'expand') || 'expand'
|
||||
const mode = isMobile ? 'collapse' : 'expand'
|
||||
setAppSidebarExpand(isMobile ? mode : localeMode)
|
||||
}, [isMobile, setAppSidebarExpand])
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { FullScreenLoading } from '@/app/components/full-screen-loading'
|
||||
import EducationApplyPage from '@/app/education-apply/education-apply-page'
|
||||
import RootLoading from '@/app/loading'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
useRouter,
|
||||
@ -28,7 +28,7 @@ export default function EducationApply() {
|
||||
}, [enableEducationPlan, isFetchedPlanInfo, router, token])
|
||||
|
||||
if (!isFetchedPlanInfo || !enableEducationPlan || !token || isLoadingEducationAccountInfo)
|
||||
return <RootLoading />
|
||||
return <FullScreenLoading />
|
||||
|
||||
return <EducationApplyPage />
|
||||
}
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RootLoading from '@/app/loading'
|
||||
import { isLegacyBase401 } from '@/service/use-common'
|
||||
import { FullScreenLoading } from '@/app/components/full-screen-loading'
|
||||
import { isLegacyBase401 } from '@/features/account-profile/client'
|
||||
|
||||
type Props = {
|
||||
error: Error & { digest?: string }
|
||||
@ -18,7 +18,7 @@ export default function CommonLayoutError({ error, unstable_retry }: Props) {
|
||||
// Showing the "Try again" button here would just flash for a few frames before
|
||||
// the page navigates away, and clicking it would 401 again anyway.
|
||||
if (isLegacyBase401(error))
|
||||
return <RootLoading />
|
||||
return <FullScreenLoading />
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body">
|
||||
|
||||
86
web/app/(commonLayout)/hydration-boundary.tsx
Normal file
86
web/app/(commonLayout)/hydration-boundary.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { getQueryClientServer } from '@/context/query-client-server'
|
||||
import { resolveServerConsoleApiUrl, serverUserProfileQueryOptions } from '@/features/account-profile/server'
|
||||
import { headers } from '@/next/headers'
|
||||
import { redirect } from '@/next/navigation'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
|
||||
const CURRENT_SEARCH_HEADER = 'x-dify-search'
|
||||
const ACCOUNT_PROFILE_PATH = '/account/profile'
|
||||
const AUTH_REFRESH_PATH = '/auth/refresh'
|
||||
|
||||
type ConsoleErrorPayload = {
|
||||
code?: string
|
||||
}
|
||||
|
||||
const isConsoleErrorPayload = (value: unknown): value is ConsoleErrorPayload =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const parseConsoleErrorPayload = async (error: Response): Promise<ConsoleErrorPayload | null> => {
|
||||
try {
|
||||
const payload: unknown = await error.clone().json()
|
||||
return isConsoleErrorPayload(payload) ? payload : null
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentPath = async () => {
|
||||
const requestHeaders = await headers()
|
||||
const pathname = requestHeaders.get(CURRENT_PATHNAME_HEADER) || `${basePath}/apps`
|
||||
const search = requestHeaders.get(CURRENT_SEARCH_HEADER) || ''
|
||||
return `${pathname}${search}`
|
||||
}
|
||||
|
||||
const redirectToAuthRefresh = async () => {
|
||||
const currentPath = await getCurrentPath()
|
||||
redirect(`${basePath}${AUTH_REFRESH_PATH}?redirect_url=${encodeURIComponent(currentPath)}`)
|
||||
}
|
||||
|
||||
const handleProfileError = async (error: unknown) => {
|
||||
if (!(error instanceof Response))
|
||||
throw error
|
||||
|
||||
const errorData = await parseConsoleErrorPayload(error)
|
||||
if (errorData?.code === 'not_setup')
|
||||
redirect(`${basePath}/install`)
|
||||
if (errorData?.code === 'not_init_validated')
|
||||
redirect(`${basePath}/init`)
|
||||
if (error.status === 401)
|
||||
await redirectToAuthRefresh()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
export async function CommonLayoutHydrationBoundary({ children }: { children: ReactNode }) {
|
||||
const queryClient = getQueryClientServer()
|
||||
const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
|
||||
|
||||
if (!accountProfileUrl) {
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
{children}
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.fetchQuery(serverUserProfileQueryOptions()),
|
||||
queryClient.prefetchQuery(systemFeaturesQueryOptions()),
|
||||
])
|
||||
}
|
||||
catch (error) {
|
||||
await handleProfileError(error)
|
||||
}
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
{children}
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
@ -1,27 +1,31 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { AppInitializer } from '@/app/components/app-initializer'
|
||||
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
|
||||
import Zendesk from '@/app/components/base/zendesk'
|
||||
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
|
||||
import { GotoAnything } from '@/app/components/goto-anything'
|
||||
import Header from '@/app/components/header'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
|
||||
import ReadmePanel from '@/app/components/plugins/readme-panel'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
import { ModalContextProvider } from '@/context/modal-context-provider'
|
||||
import { ProviderContextProvider } from '@/context/provider-context-provider'
|
||||
import PartnerStack from '../components/billing/partner-stack'
|
||||
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
|
||||
import RoleRouteGuard from './role-route-guard'
|
||||
|
||||
const Layout = ({ children }: { children: ReactNode }) => {
|
||||
const Layout = async ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GoogleAnalyticsScripts />
|
||||
<AmplitudeProvider />
|
||||
<AppInitializer>
|
||||
<OAuthRegistrationAnalytics />
|
||||
<EducationVerifyActionRecorder />
|
||||
<CommonLayoutHydrationBoundary>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
@ -40,8 +44,8 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
<Zendesk />
|
||||
</AppInitializer>
|
||||
</CommonLayoutHydrationBoundary>
|
||||
<Zendesk />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
||||
export default function CommonLayoutLoading() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
|
||||
|
||||
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetPage snippetId={snippetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@ -1,21 +0,0 @@
|
||||
import Page from './page'
|
||||
|
||||
const mockRedirect = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
redirect: (path: string) => mockRedirect(path),
|
||||
}))
|
||||
|
||||
describe('snippet detail redirect page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should redirect legacy snippet detail routes to orchestrate', async () => {
|
||||
await Page({
|
||||
params: Promise.resolve({ snippetId: 'snippet-1' }),
|
||||
})
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
|
||||
})
|
||||
})
|
||||
@ -1,11 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
redirect(`/snippets/${snippetId}/orchestrate`)
|
||||
}
|
||||
|
||||
export default Page
|
||||
@ -1,7 +0,0 @@
|
||||
import SnippetList from '@/app/components/snippet-list'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <SnippetList />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@ -216,7 +216,6 @@ const EmailChangeModal = ({ onClose, email }: Props) => {
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
|
||||
localStorage.removeItem('setup_status')
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
router.push('/signin')
|
||||
|
||||
@ -16,10 +16,10 @@ import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import Collapse from '@/app/components/header/account-setting/collapse'
|
||||
import { IS_CE_EDITION, validPassword } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common'
|
||||
import DeleteAccount from '../delete-account'
|
||||
|
||||
import AvatarWithEdit from './AvatarWithEdit'
|
||||
@ -49,7 +49,7 @@ export default function AccountPage() {
|
||||
// Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously.
|
||||
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
|
||||
const userProfile = userProfileResp.profile
|
||||
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile })
|
||||
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
|
||||
const { isEducationAccount } = useProviderContext()
|
||||
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
|
||||
const [editName, setEditName] = useState('')
|
||||
|
||||
@ -12,8 +12,9 @@ import { useTranslation } from 'react-i18next'
|
||||
import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useLogout, userProfileQueryOptions } from '@/service/use-common'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
|
||||
export default function AppSelector() {
|
||||
const router = useRouter()
|
||||
@ -31,7 +32,6 @@ export default function AppSelector() {
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
|
||||
localStorage.removeItem('setup_status')
|
||||
resetUser()
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
|
||||
@ -1,21 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { AppInitializer } from '@/app/components/app-initializer'
|
||||
import { CommonLayoutHydrationBoundary } from '@/app/(commonLayout)/hydration-boundary'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
|
||||
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
import { ModalContextProvider } from '@/context/modal-context-provider'
|
||||
import { ProviderContextProvider } from '@/context/provider-context-provider'
|
||||
import Header from './header'
|
||||
|
||||
const Layout = ({ children }: { children: ReactNode }) => {
|
||||
const Layout = async ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GoogleAnalyticsScripts />
|
||||
<AmplitudeProvider />
|
||||
<AppInitializer>
|
||||
<OAuthRegistrationAnalytics />
|
||||
<EducationVerifyActionRecorder />
|
||||
<CommonLayoutHydrationBoundary>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
@ -30,7 +34,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
</AppInitializer>
|
||||
</CommonLayoutHydrationBoundary>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,9 +5,9 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Header from '@/app/signin/_header'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
@ -16,9 +16,9 @@ import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { setOAuthPendingRedirect } from '@/app/signin/utils/post-login-redirect'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { isLegacyBase401, useLogout, userProfileQueryOptions } from '@/service/use-common'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
|
||||
|
||||
function buildReturnUrl(pathname: string, search: string) {
|
||||
@ -80,7 +80,6 @@ export default function OAuthAuthorize() {
|
||||
const onLoginSwitchClick = async () => {
|
||||
try {
|
||||
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`)
|
||||
setOAuthPendingRedirect(returnUrl)
|
||||
if (isLoggedIn)
|
||||
await logout()
|
||||
router.push(`/signin?redirect_url=${encodeURIComponent(returnUrl)}`)
|
||||
|
||||
104
web/app/auth/refresh/__tests__/route.spec.ts
Normal file
104
web/app/auth/refresh/__tests__/route.spec.ts
Normal file
@ -0,0 +1,104 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: 'http://localhost:5001/console/api',
|
||||
}))
|
||||
|
||||
vi.mock('@/config/server', () => ({
|
||||
SERVER_CONSOLE_API_PREFIX: undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
basePath: '',
|
||||
}))
|
||||
|
||||
const getSetCookieHeaders = (headers: Headers) => {
|
||||
const getSetCookie = Reflect.get(headers, 'getSetCookie')
|
||||
|
||||
if (typeof getSetCookie === 'function') {
|
||||
const values: unknown = getSetCookie.call(headers)
|
||||
return Array.isArray(values) ? values : []
|
||||
}
|
||||
|
||||
const setCookie = headers.get('set-cookie')
|
||||
return setCookie ? [setCookie] : []
|
||||
}
|
||||
|
||||
const createRequest = (url: string, cookie?: string) => ({
|
||||
url,
|
||||
headers: new Headers(cookie ? { cookie } : undefined),
|
||||
}) as Request
|
||||
|
||||
describe('auth refresh route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should refresh cookies and redirect back to the requested path', async () => {
|
||||
const headers = new Headers()
|
||||
Object.defineProperty(headers, 'getSetCookie', {
|
||||
value: () => [
|
||||
'access_token=new-access; Path=/; HttpOnly',
|
||||
'refresh_token=new-refresh; Path=/; HttpOnly',
|
||||
],
|
||||
})
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers,
|
||||
} as Response)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const { GET } = await import('../route')
|
||||
|
||||
const response = await GET(createRequest(
|
||||
'http://localhost:3000/auth/refresh?redirect_url=%2Fapps%3Fcategory%3Dworkflow',
|
||||
'refresh_token=old-refresh',
|
||||
))
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:5001/console/api/refresh-token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
)
|
||||
const fetchHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
expect(fetchHeaders.get('cookie')).toBe('refresh_token=old-refresh')
|
||||
expect(response.status).toBe(303)
|
||||
expect(response.headers.get('location')).toBe('http://localhost:3000/apps?category=workflow')
|
||||
expect(getSetCookieHeaders(response.headers)).toEqual([
|
||||
'access_token=new-access; Path=/; HttpOnly',
|
||||
'refresh_token=new-refresh; Path=/; HttpOnly',
|
||||
])
|
||||
})
|
||||
|
||||
it('should redirect to signin when refresh token is rejected', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
|
||||
const { GET } = await import('../route')
|
||||
|
||||
const response = await GET(createRequest(
|
||||
'http://localhost:3000/auth/refresh?redirect_url=%2Fapps',
|
||||
'refresh_token=expired',
|
||||
))
|
||||
|
||||
expect(response.status).toBe(303)
|
||||
expect(response.headers.get('location')).toBe('http://localhost:3000/signin?redirect_url=%2Fapps')
|
||||
})
|
||||
|
||||
it('should ignore cross-origin redirect targets', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 401 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const { GET } = await import('../route')
|
||||
|
||||
const response = await GET(createRequest(
|
||||
'http://localhost:3000/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
|
||||
'refresh_token=expired',
|
||||
))
|
||||
|
||||
expect(response.status).toBe(303)
|
||||
expect(response.headers.get('location')).toBe('http://localhost:3000/signin?redirect_url=%2Fapps')
|
||||
})
|
||||
})
|
||||
113
web/app/auth/refresh/route.ts
Normal file
113
web/app/auth/refresh/route.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { SERVER_CONSOLE_API_PREFIX } from '@/config/server'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const REFRESH_TOKEN_PATH = '/refresh-token'
|
||||
const AUTH_REFRESH_PATH = `${basePath}/auth/refresh`
|
||||
const DEFAULT_REDIRECT_PATH = `${basePath}/apps`
|
||||
|
||||
const withTrailingSlash = (value: string) => value.endsWith('/') ? value : `${value}/`
|
||||
const withoutLeadingSlash = (value: string) => value.startsWith('/') ? value.slice(1) : value
|
||||
|
||||
const resolveAbsoluteUrlPrefix = (value: string) => {
|
||||
try {
|
||||
return new URL(value).toString()
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const resolveServerConsoleApiUrl = (pathname: string, requestUrl: URL) => {
|
||||
const requestPath = withoutLeadingSlash(pathname)
|
||||
const apiPrefix = SERVER_CONSOLE_API_PREFIX
|
||||
|| resolveAbsoluteUrlPrefix(API_PREFIX)
|
||||
|| new URL(API_PREFIX, requestUrl.origin).toString()
|
||||
|
||||
if (!apiPrefix)
|
||||
return null
|
||||
|
||||
return new URL(requestPath, withTrailingSlash(apiPrefix)).toString()
|
||||
}
|
||||
|
||||
const resolveSafeRedirectPath = (request: Request) => {
|
||||
const requestUrl = new URL(request.url)
|
||||
const redirectUrl = requestUrl.searchParams.get('redirect_url')
|
||||
|
||||
if (!redirectUrl)
|
||||
return DEFAULT_REDIRECT_PATH
|
||||
|
||||
try {
|
||||
const target = new URL(redirectUrl, requestUrl.origin)
|
||||
if (target.origin !== requestUrl.origin)
|
||||
return DEFAULT_REDIRECT_PATH
|
||||
if (target.pathname === AUTH_REFRESH_PATH)
|
||||
return DEFAULT_REDIRECT_PATH
|
||||
|
||||
return `${target.pathname}${target.search}`
|
||||
}
|
||||
catch {
|
||||
return DEFAULT_REDIRECT_PATH
|
||||
}
|
||||
}
|
||||
|
||||
const getSetCookieHeaders = (headers: Headers) => {
|
||||
const getSetCookie = Reflect.get(headers, 'getSetCookie')
|
||||
|
||||
if (typeof getSetCookie === 'function') {
|
||||
const values: unknown = getSetCookie.call(headers)
|
||||
return Array.isArray(values)
|
||||
? values.filter((value): value is string => typeof value === 'string')
|
||||
: []
|
||||
}
|
||||
|
||||
const setCookie = headers.get('set-cookie')
|
||||
return setCookie ? [setCookie] : []
|
||||
}
|
||||
|
||||
const createRedirectResponse = (request: Request, pathname: string, setCookies: string[] = []) => {
|
||||
const headers = new Headers({
|
||||
'Cache-Control': 'no-store',
|
||||
'Location': new URL(pathname, request.url).toString(),
|
||||
})
|
||||
|
||||
for (const cookie of setCookies)
|
||||
headers.append('Set-Cookie', cookie)
|
||||
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
const createSigninRedirectResponse = (request: Request, redirectPath: string) =>
|
||||
createRedirectResponse(request, `${basePath}/signin?redirect_url=${encodeURIComponent(redirectPath)}`)
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const requestUrl = new URL(request.url)
|
||||
const redirectPath = resolveSafeRedirectPath(request)
|
||||
const refreshUrl = resolveServerConsoleApiUrl(REFRESH_TOKEN_PATH, requestUrl)
|
||||
const cookie = request.headers.get('cookie')
|
||||
|
||||
if (!refreshUrl || !cookie)
|
||||
return createSigninRedirectResponse(request, redirectPath)
|
||||
|
||||
try {
|
||||
const response = await fetch(refreshUrl, {
|
||||
method: 'POST',
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
cookie,
|
||||
}),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok)
|
||||
return createSigninRedirectResponse(request, redirectPath)
|
||||
|
||||
return createRedirectResponse(request, redirectPath, getSetCookieHeaders(response.headers))
|
||||
}
|
||||
catch {
|
||||
return createSigninRedirectResponse(request, redirectPath)
|
||||
}
|
||||
}
|
||||
@ -1,197 +0,0 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||
} from '@/app/education-apply/constants'
|
||||
import { resolvePostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
|
||||
import { AppInitializer } from '../app-initializer'
|
||||
|
||||
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
|
||||
mockSendGAEvent: vi.fn(),
|
||||
mockTrackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/setup-status', () => ({
|
||||
fetchSetupStatusWithCache: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/signin/utils/post-login-redirect', () => ({
|
||||
resolvePostLoginRedirect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/gtag', () => ({
|
||||
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
const mockUsePathname = vi.mocked(usePathname)
|
||||
const mockUseRouter = vi.mocked(useRouter)
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||
const mockFetchSetupStatusWithCache = vi.mocked(fetchSetupStatusWithCache)
|
||||
const mockResolvePostLoginRedirect = vi.mocked(resolvePostLoginRedirect)
|
||||
const mockReplace = vi.fn()
|
||||
|
||||
describe('AppInitializer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
window.localStorage.clear()
|
||||
window.sessionStorage.clear()
|
||||
Cookies.remove('utm_info')
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockUsePathname.mockReturnValue('/apps')
|
||||
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>)
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
|
||||
mockFetchSetupStatusWithCache.mockResolvedValue({ step: 'finished' })
|
||||
mockResolvePostLoginRedirect.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('renders children after setup checks finish', async () => {
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(mockFetchSetupStatusWithCache).toHaveBeenCalledTimes(1)
|
||||
expect(mockReplace).not.toHaveBeenCalledWith('/signin')
|
||||
})
|
||||
|
||||
it('redirects to install when setup status loading fails', async () => {
|
||||
mockFetchSetupStatusWithCache.mockRejectedValue(new Error('unauthorized'))
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/install'))
|
||||
expect(screen.queryByText('ready')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not persist create app attribution from the url anymore', async () => {
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
|
||||
})
|
||||
|
||||
it('tracks oauth registration with utm info and clears the cookie', async () => {
|
||||
Cookies.set('utm_info', JSON.stringify({
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
}))
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
{ searchParams: 'oauth_new_user=true' },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'oauth',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'oauth',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the base registration event when the oauth utm cookie is invalid', async () => {
|
||||
Cookies.set('utm_info', '{invalid-json')
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
{ searchParams: 'oauth_new_user=true' },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
method: 'oauth',
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
method: 'oauth',
|
||||
})
|
||||
expect(console.error).toHaveBeenCalled()
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stores the education verification flag in localStorage', async () => {
|
||||
mockUseSearchParams.mockReturnValue(
|
||||
new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType<typeof useSearchParams>,
|
||||
)
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes')
|
||||
})
|
||||
|
||||
it('redirects to the resolved post-login url when one exists', async () => {
|
||||
const mockLocationReplace = vi.fn()
|
||||
vi.stubGlobal('location', { ...window.location, replace: mockLocationReplace })
|
||||
mockResolvePostLoginRedirect.mockReturnValue('/explore')
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockLocationReplace).toHaveBeenCalledWith('/explore'))
|
||||
expect(screen.queryByText('ready')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('redirects to signin when redirect resolution throws', async () => {
|
||||
mockResolvePostLoginRedirect.mockImplementation(() => {
|
||||
throw new Error('redirect resolution failed')
|
||||
})
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/signin'))
|
||||
expect(screen.queryByText('ready')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,40 @@
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||
} from '@/app/education-apply/constants'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { EducationVerifyActionRecorder } from '../education-verify-action-recorder'
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||
|
||||
describe('EducationVerifyActionRecorder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.localStorage.clear()
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
|
||||
})
|
||||
|
||||
it('should store the education verification flag when the callback action is present', async () => {
|
||||
mockUseSearchParams.mockReturnValue(
|
||||
new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType<typeof useSearchParams>,
|
||||
)
|
||||
|
||||
render(<EducationVerifyActionRecorder />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes')
|
||||
})
|
||||
})
|
||||
|
||||
it('should leave localStorage unchanged for unrelated routes', () => {
|
||||
render(<EducationVerifyActionRecorder />)
|
||||
|
||||
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,106 @@
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
|
||||
|
||||
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
|
||||
mockSendGAEvent: vi.fn(),
|
||||
mockTrackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/gtag', () => ({
|
||||
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||
|
||||
const setSearchParams = (searchParams = '') => {
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams(searchParams) as unknown as ReturnType<typeof useSearchParams>)
|
||||
window.history.replaceState(null, '', `/signin${searchParams ? `?${searchParams}` : ''}`)
|
||||
}
|
||||
|
||||
describe('OAuthRegistrationAnalytics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Cookies.remove('utm_info')
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setSearchParams()
|
||||
})
|
||||
|
||||
it('should track oauth registration with utm info and clear the query flag', async () => {
|
||||
Cookies.set('utm_info', JSON.stringify({
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
}))
|
||||
|
||||
setSearchParams('oauth_new_user=true&source=signin')
|
||||
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'oauth',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'oauth',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin?source=signin')
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to the base registration event when the utm cookie is invalid', async () => {
|
||||
Cookies.set('utm_info', '{invalid-json')
|
||||
|
||||
setSearchParams('oauth_new_user=true')
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
method: 'oauth',
|
||||
})
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
method: 'oauth',
|
||||
})
|
||||
expect(console.error).toHaveBeenCalled()
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should do nothing without the oauth registration query flag', () => {
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear a false oauth registration query flag without tracking', async () => {
|
||||
setSearchParams('oauth_new_user=false')
|
||||
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
|
||||
})
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,103 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||
} from '@/app/education-apply/constants'
|
||||
import RootLoading from '@/app/loading'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { sendGAEvent } from '@/utils/gtag'
|
||||
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
|
||||
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
|
||||
import { trackEvent } from './base/amplitude'
|
||||
|
||||
type AppInitializerProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const AppInitializer = ({
|
||||
children,
|
||||
}: AppInitializerProps) => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
// Tokens are now stored in cookies, no need to check localStorage
|
||||
const pathname = usePathname()
|
||||
const [init, setInit] = useState(false)
|
||||
const [oauthNewUser] = useQueryState(
|
||||
'oauth_new_user',
|
||||
parseAsBoolean.withOptions({ history: 'replace' }),
|
||||
)
|
||||
const isSetupFinished = useCallback(async () => {
|
||||
try {
|
||||
const setUpStatus = await fetchSetupStatusWithCache()
|
||||
return setUpStatus.step === 'finished'
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const action = searchParams.get('action')
|
||||
|
||||
if (oauthNewUser) {
|
||||
let utmInfo = null
|
||||
const utmInfoStr = Cookies.get('utm_info')
|
||||
if (utmInfoStr) {
|
||||
try {
|
||||
utmInfo = JSON.parse(utmInfoStr)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse utm_info cookie:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Track registration event with UTM params
|
||||
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
|
||||
method: 'oauth',
|
||||
...utmInfo,
|
||||
})
|
||||
|
||||
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
|
||||
method: 'oauth',
|
||||
...utmInfo,
|
||||
})
|
||||
|
||||
Cookies.remove('utm_info')
|
||||
}
|
||||
|
||||
if (oauthNewUser !== null)
|
||||
router.replace(pathname)
|
||||
|
||||
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
|
||||
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
|
||||
|
||||
try {
|
||||
const isFinished = await isSetupFinished()
|
||||
if (!isFinished) {
|
||||
router.replace('/install')
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUrl = resolvePostLoginRedirect(searchParams)
|
||||
if (redirectUrl) {
|
||||
location.replace(redirectUrl)
|
||||
return
|
||||
}
|
||||
|
||||
setInit(true)
|
||||
}
|
||||
catch {
|
||||
router.replace('/signin')
|
||||
}
|
||||
})()
|
||||
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser])
|
||||
|
||||
return init ? children : <RootLoading />
|
||||
}
|
||||
@ -168,21 +168,6 @@ describe('AppDetailNav', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom header and navigation when provided', () => {
|
||||
render(
|
||||
<AppDetailNav
|
||||
navigation={navigation}
|
||||
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
|
||||
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow canvas mode', () => {
|
||||
|
||||
@ -28,16 +28,12 @@ type IAppDetailNavProps = {
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
appInfoActions?: AppInfoActions
|
||||
}
|
||||
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
iconType = 'app',
|
||||
appInfoActions,
|
||||
}: IAppDetailNavProps) => {
|
||||
@ -116,20 +112,18 @@ const AppDetailNav = ({
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{renderHeader
|
||||
? renderHeader(appSidebarExpand)
|
||||
: iconType === 'app' && (
|
||||
appInfoActions
|
||||
? (
|
||||
<AppInfoView
|
||||
expand={expand}
|
||||
actions={appInfoActions}
|
||||
renderDetail={false}
|
||||
/>
|
||||
)
|
||||
: <AppInfo expand={expand} />
|
||||
)}
|
||||
{!renderHeader && iconType !== 'app' && (
|
||||
{iconType === 'app' && (
|
||||
appInfoActions
|
||||
? (
|
||||
<AppInfoView
|
||||
expand={expand}
|
||||
actions={appInfoActions}
|
||||
renderDetail={false}
|
||||
/>
|
||||
)
|
||||
: <AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
@ -158,20 +152,18 @@ const AppDetailNav = ({
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{renderNavigation
|
||||
? renderNavigation(appSidebarExpand)
|
||||
: navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode={appSidebarExpand}
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode={appSidebarExpand}
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
|
||||
</div>
|
||||
|
||||
@ -262,20 +262,4 @@ describe('NavLink Animation and Layout Issues', () => {
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Mode', () => {
|
||||
it('should render as an interactive button when href is omitted', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
|
||||
|
||||
const buttonElement = screen.getByText('Orchestrate').closest('button')
|
||||
expect(buttonElement).not.toBeNull()
|
||||
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
|
||||
buttonElement?.click()
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,15 +14,13 @@ export type NavIcon = React.ComponentType<
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
href?: string
|
||||
href: string
|
||||
iconMap: {
|
||||
selected: NavIcon
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
@ -31,8 +29,6 @@ const NavLink = ({
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
active,
|
||||
onClick,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
@ -43,11 +39,8 @@ const NavLink = ({
|
||||
|
||||
return res
|
||||
})()
|
||||
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
const linkClassName = cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
@ -77,32 +70,13 @@ const NavLink = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={linkClassName}
|
||||
className={cn(isActive
|
||||
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
|
||||
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3')}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
|
||||
@ -1,270 +0,0 @@
|
||||
import type { CreateSnippetDialogPayload } from '@/app/components/snippets/create-snippet-dialog'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import SnippetInfoDropdown from '../dropdown'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockDownloadBlob = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockUpdateMutate = vi.fn()
|
||||
const mockExportMutateAsync = vi.fn()
|
||||
const mockDeleteMutate = vi.fn()
|
||||
let mockDropdownOpen = false
|
||||
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
mockDropdownOpen = !!open
|
||||
mockDropdownOnOpenChange = onOpenChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
mockDropdownOpen ? <div>{children}</div> : null
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: mockUpdateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: mockExportMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: mockDeleteMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
type MockCreateSnippetDialogProps = {
|
||||
isOpen: boolean
|
||||
title?: string
|
||||
confirmText?: string
|
||||
initialValue?: {
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
onClose: () => void
|
||||
onConfirm: (payload: CreateSnippetDialogPayload) => void
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
title,
|
||||
confirmText,
|
||||
initialValue,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: MockCreateSnippetDialogProps) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="create-snippet-dialog">
|
||||
<div>{title}</div>
|
||||
<div>{confirmText}</div>
|
||||
<div>{initialValue?.name}</div>
|
||||
<div>{initialValue?.description}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm({
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})}
|
||||
>
|
||||
submit-edit
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>close-edit</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
tags: [],
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfoDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDropdownOpen = false
|
||||
mockDropdownOnOpenChange = undefined
|
||||
})
|
||||
|
||||
// Rendering coverage for the menu trigger itself.
|
||||
describe('Rendering', () => {
|
||||
it('should render the dropdown trigger button', () => {
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edit flow should seed the dialog with current snippet info and submit updates.
|
||||
describe('Edit Snippet', () => {
|
||||
it('should open the edit dialog and submit snippet updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.editInfo'))
|
||||
|
||||
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
|
||||
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
body: {
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
|
||||
})
|
||||
})
|
||||
|
||||
// Export should call the export hook and download the returned YAML blob.
|
||||
describe('Export Snippet', () => {
|
||||
it('should export and download the snippet yaml', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockResolvedValue('yaml: content')
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
|
||||
})
|
||||
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: `${mockSnippet.name}.yml`,
|
||||
})
|
||||
})
|
||||
|
||||
it('should show an error toast when export fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Delete should require confirmation and redirect after a successful mutation.
|
||||
describe('Delete Snippet', () => {
|
||||
it('should confirm deletion and redirect to the snippets list', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
|
||||
|
||||
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
|
||||
|
||||
expect(mockDeleteMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/snippets')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,60 +0,0 @@
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import SnippetInfo from '..'
|
||||
|
||||
vi.mock('../dropdown', () => ({
|
||||
default: () => <div data-testid="snippet-info-dropdown" />,
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
tags: [],
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the collapsed and expanded sidebar header states.
|
||||
describe('Rendering', () => {
|
||||
it('should render the expanded snippet details and dropdown when expand is true', () => {
|
||||
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the expanded-only content when expand is false', () => {
|
||||
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases around optional snippet fields should not break the header layout.
|
||||
describe('Edge Cases', () => {
|
||||
it('should omit the description block when the snippet has no description', () => {
|
||||
render(
|
||||
<SnippetInfo
|
||||
expand={true}
|
||||
snippet={{ ...mockSnippet, description: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,177 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
|
||||
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
|
||||
type SnippetInfoDropdownProps = {
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { replace } = useRouter()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
|
||||
const updateSnippetMutation = useUpdateSnippetMutation()
|
||||
const exportSnippetMutation = useExportSnippetMutation()
|
||||
const deleteSnippetMutation = useDeleteSnippetMutation()
|
||||
|
||||
const initialValue = React.useMemo(() => ({
|
||||
name: snippet.name,
|
||||
description: snippet.description,
|
||||
}), [snippet.description, snippet.name])
|
||||
|
||||
const handleOpenEditDialog = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
setIsEditDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleExportSnippet = React.useCallback(async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
|
||||
}
|
||||
catch {
|
||||
toast.error(t('exportFailed'))
|
||||
}
|
||||
}, [exportSnippetMutation, snippet.id, snippet.name, t])
|
||||
|
||||
const handleEditSnippet = React.useCallback(async ({ name, description }: {
|
||||
name: string
|
||||
description: string
|
||||
}) => {
|
||||
updateSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('editDone'))
|
||||
setIsEditDialogOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('editFailed'))
|
||||
},
|
||||
})
|
||||
}, [snippet.id, t, updateSnippetMutation])
|
||||
|
||||
const handleDeleteSnippet = React.useCallback(() => {
|
||||
deleteSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('deleted'))
|
||||
setIsDeleteDialogOpen(false)
|
||||
replace('/snippets')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
|
||||
},
|
||||
})
|
||||
}, [deleteSnippetMutation, replace, snippet.id, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[180px] p-1"
|
||||
>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.editInfo')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.exportSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-1! bg-divider-subtle" />
|
||||
<DropdownMenuItem
|
||||
className="mx-0 gap-2"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span className="grow">{t('menu.deleteSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isEditDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
initialValue={initialValue}
|
||||
title={t('editDialogTitle')}
|
||||
confirmText={t('operation.save', { ns: 'common' })}
|
||||
isSubmitting={updateSnippetMutation.isPending}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onConfirm={handleEditSnippet}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent className="w-100">
|
||||
<div className="space-y-2 p-6">
|
||||
<AlertDialogTitle className="title-md-semi-bold text-text-primary">
|
||||
{t('deleteConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
|
||||
{t('deleteConfirmContent')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="pt-0">
|
||||
<AlertDialogCancelButton>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={deleteSnippetMutation.isPending}
|
||||
onClick={handleDeleteSnippet}
|
||||
>
|
||||
{t('menu.deleteSnippet')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfoDropdown)
|
||||
@ -1,46 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SnippetInfoDropdown from './dropdown'
|
||||
|
||||
type SnippetInfoProps = {
|
||||
expand: boolean
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const SnippetInfo = ({
|
||||
expand,
|
||||
snippet,
|
||||
}: SnippetInfoProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
if (!expand)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-2 pt-2 pb-1">
|
||||
<div className="flex flex-col gap-2 rounded-xl p-2">
|
||||
<div className="flex items-center justify-end">
|
||||
<SnippetInfoDropdown snippet={snippet} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate system-md-semibold text-text-secondary">
|
||||
{snippet.name}
|
||||
</div>
|
||||
<div className="pt-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('typeLabel')}
|
||||
</div>
|
||||
</div>
|
||||
{snippet.description && (
|
||||
<p className="line-clamp-3 system-xs-regular break-words text-text-tertiary">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfo)
|
||||
@ -1,6 +1,7 @@
|
||||
import type { AppPublisherProps, AppPublisherPublishParams } from '@/app/components/app/app-publisher'
|
||||
import type { Features, FileUpload } from '@/app/components/base/features/types'
|
||||
import type { ModelConfig } from '@/models/debug'
|
||||
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@ -20,15 +21,9 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { Resolution } from '@/types/app'
|
||||
|
||||
type PublishedModelConfig = ModelConfig & {
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
type Props = Omit<AppPublisherProps, 'onPublish'> & {
|
||||
onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise<unknown> | unknown
|
||||
publishedConfig: {
|
||||
modelConfig: PublishedModelConfig
|
||||
}
|
||||
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
|
||||
publishedConfig?: any
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
@ -76,7 +71,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
setRestoreConfirmOpen(false)
|
||||
}, [featuresStore, props])
|
||||
|
||||
const handlePublish = useCallback((params?: AppPublisherPublishParams) => {
|
||||
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
return props.onPublish?.(params, features)
|
||||
}, [features, props])
|
||||
|
||||
|
||||
@ -85,10 +85,8 @@ export type AppPublisherProps = {
|
||||
|
||||
const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
|
||||
|
||||
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
|
||||
|
||||
type AppPublisherPublishHandler
|
||||
= | ((params?: AppPublisherPublishParams) => Promise<unknown> | unknown)
|
||||
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
|
||||
| ((params?: unknown) => Promise<unknown> | unknown)
|
||||
|
||||
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown
|
||||
|
||||
@ -211,12 +211,6 @@ describe('ConfigModalFormFields', () => {
|
||||
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
textInputView.unmount()
|
||||
|
||||
const hiddenFieldDisabledProps = createBaseProps()
|
||||
const hiddenFieldDisabledView = render(<ConfigModalFormFields {...hiddenFieldDisabledProps} showHiddenField={false} />)
|
||||
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument()
|
||||
hiddenFieldDisabledView.unmount()
|
||||
|
||||
const singleFileProps = createBaseProps()
|
||||
singleFileProps.tempPayload = {
|
||||
...singleFileProps.tempPayload,
|
||||
|
||||
@ -49,7 +49,6 @@ type ConfigModalFormFieldsProps = {
|
||||
onVarNameChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
options?: string[]
|
||||
selectOptions: SelectOptionItem[]
|
||||
showHiddenField?: boolean
|
||||
tempPayload: InputVar
|
||||
t: Translate
|
||||
}
|
||||
@ -68,7 +67,6 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
onVarNameChange,
|
||||
options,
|
||||
selectOptions,
|
||||
showHiddenField = true,
|
||||
tempPayload,
|
||||
t,
|
||||
}) => {
|
||||
@ -244,7 +242,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
|
||||
</label>
|
||||
|
||||
{showHiddenField && !isFileInput && (
|
||||
{!isFileInput && (
|
||||
<div className="mt-5! flex h-6 items-center gap-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
|
||||
@ -33,7 +33,6 @@ type IConfigModalProps = {
|
||||
onClose: () => void
|
||||
onConfirm: (newValue: InputVar, moreInfo?: MoreInfo) => void
|
||||
supportFile?: boolean
|
||||
showHiddenField?: boolean
|
||||
}
|
||||
|
||||
const ConfigModal: FC<IConfigModalProps> = ({
|
||||
@ -42,7 +41,6 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
isShow,
|
||||
onClose,
|
||||
onConfirm,
|
||||
showHiddenField,
|
||||
supportFile,
|
||||
}) => {
|
||||
const { modelConfig } = useContext(ConfigContext)
|
||||
@ -175,7 +173,6 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
onVarNameChange={handleVarNameChange}
|
||||
options={options}
|
||||
selectOptions={selectOptions}
|
||||
showHiddenField={showHiddenField}
|
||||
tempPayload={tempPayload}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@ -96,7 +96,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType,
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
completion_params: {} as CompletionParams,
|
||||
})
|
||||
const {
|
||||
|
||||
@ -78,7 +78,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType,
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
completion_params: defaultCompletionParams,
|
||||
})
|
||||
const {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher'
|
||||
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { Features as FeaturesData, OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
@ -22,6 +21,7 @@ import type {
|
||||
TextToSpeechConfig,
|
||||
} from '@/models/debug'
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import { clone } from 'es-toolkit/object'
|
||||
import { produce } from 'immer'
|
||||
@ -481,7 +481,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
resolvedModelModeType,
|
||||
])
|
||||
|
||||
const onPublish = useCallback(async (params?: AppPublisherPublishParams, features?: FeaturesData) => {
|
||||
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
|
||||
const modelAndParameter = params && 'model' in params && 'provider' in params && 'parameters' in params
|
||||
? params
|
||||
: undefined
|
||||
|
||||
@ -346,40 +346,29 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
|
||||
|
||||
function AppPreview({ mode }: { mode: AppModeEnum }) {
|
||||
const { t } = useTranslation()
|
||||
const previewInfo = (() => {
|
||||
switch (mode) {
|
||||
case AppModeEnum.CHAT:
|
||||
return {
|
||||
title: t('types.chatbot', { ns: 'app' }),
|
||||
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.ADVANCED_CHAT:
|
||||
return {
|
||||
title: t('types.advanced', { ns: 'app' }),
|
||||
description: t('newApp.advancedUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.AGENT_CHAT:
|
||||
return {
|
||||
title: t('types.agent', { ns: 'app' }),
|
||||
description: t('newApp.agentUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.COMPLETION:
|
||||
return {
|
||||
title: t('newApp.completeApp', { ns: 'app' }),
|
||||
description: t('newApp.completionUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.WORKFLOW:
|
||||
return {
|
||||
title: t('types.workflow', { ns: 'app' }),
|
||||
description: t('newApp.workflowUserDescription', { ns: 'app' }),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
title: t('types.workflow', { ns: 'app' }),
|
||||
description: t('newApp.workflowUserDescription', { ns: 'app' }),
|
||||
}
|
||||
}
|
||||
})()
|
||||
const modeToPreviewInfoMap = {
|
||||
[AppModeEnum.CHAT]: {
|
||||
title: t('types.chatbot', { ns: 'app' }),
|
||||
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.ADVANCED_CHAT]: {
|
||||
title: t('types.advanced', { ns: 'app' }),
|
||||
description: t('newApp.advancedUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.AGENT_CHAT]: {
|
||||
title: t('types.agent', { ns: 'app' }),
|
||||
description: t('newApp.agentUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.COMPLETION]: {
|
||||
title: t('newApp.completeApp', { ns: 'app' }),
|
||||
description: t('newApp.completionUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.WORKFLOW]: {
|
||||
title: t('types.workflow', { ns: 'app' }),
|
||||
description: t('newApp.workflowUserDescription', { ns: 'app' }),
|
||||
},
|
||||
}
|
||||
const previewInfo = modeToPreviewInfoMap[mode]
|
||||
return (
|
||||
<div className="px-8 py-4">
|
||||
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>
|
||||
|
||||
@ -2,8 +2,6 @@ import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Empty from '../empty'
|
||||
|
||||
const defaultMessage = 'workflow.tabs.noSnippetsFound'
|
||||
|
||||
describe('Empty', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -11,32 +9,32 @@ describe('Empty', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Empty message={defaultMessage} />)
|
||||
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render 36 placeholder cards', () => {
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const { container } = render(<Empty />)
|
||||
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
|
||||
expect(placeholderCards).toHaveLength(36)
|
||||
})
|
||||
|
||||
it('should display the provided message', () => {
|
||||
render(<Empty message="app.newApp.noAppsFound" />)
|
||||
it('should display the no apps found message', () => {
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct container styling for overlay', () => {
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const { container } = render(<Empty />)
|
||||
const overlay = container.querySelector('.pointer-events-none')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
|
||||
})
|
||||
|
||||
it('should have correct styling for placeholder cards', () => {
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const { container } = render(<Empty />)
|
||||
const card = container.querySelector('.bg-background-default-lighter')
|
||||
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
|
||||
})
|
||||
@ -44,10 +42,10 @@ describe('Empty', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(<Empty message={defaultMessage} />)
|
||||
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
|
||||
const { rerender } = render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
|
||||
rerender(<Empty message="app.newApp.noAppsFound" />)
|
||||
rerender(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -47,22 +47,16 @@ vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
userProfile: { id: 'creator-1' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetKeywords = vi.fn()
|
||||
<<<<<<< HEAD
|
||||
const mockSetTagIDs = vi.fn()
|
||||
const mockSetCreatorID = vi.fn()
|
||||
=======
|
||||
const mockSetIsCreatedByMe = vi.fn()
|
||||
>>>>>>> main
|
||||
const mockSetCategory = vi.fn()
|
||||
const mockQueryState = {
|
||||
category: 'all',
|
||||
keywords: '',
|
||||
creatorID: '',
|
||||
isCreatedByMe: false,
|
||||
}
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
|
||||
@ -70,23 +64,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
query: mockQueryState,
|
||||
setCategory: mockSetCategory,
|
||||
setKeywords: mockSetKeywords,
|
||||
<<<<<<< HEAD
|
||||
setTagIDs: mockSetTagIDs,
|
||||
setCreatorID: mockSetCreatorID,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({
|
||||
data: {
|
||||
accounts: [
|
||||
{ id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' },
|
||||
{ id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' },
|
||||
],
|
||||
},
|
||||
=======
|
||||
setIsCreatedByMe: mockSetIsCreatedByMe,
|
||||
>>>>>>> main
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -221,9 +199,9 @@ vi.mock('../app-card', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../new-app-card', () => ({
|
||||
default: ({ ref: _ref }: { ref?: React.Ref<HTMLDivElement> }) => {
|
||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button', 'ref': _ref }, 'New App Card')
|
||||
},
|
||||
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
|
||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../empty', () => ({
|
||||
@ -260,20 +238,12 @@ beforeAll(() => {
|
||||
|
||||
// Render helper wrapping with shared nuqs testing helper plus a seeded
|
||||
// systemFeatures cache so List can resolve its useSuspenseQuery.
|
||||
<<<<<<< HEAD
|
||||
const renderList = (searchParams = '', pageType: 'apps' | 'snippets' = 'apps') => {
|
||||
=======
|
||||
const renderList = (searchParams = '') => {
|
||||
mockSearchParams = new URLSearchParams(searchParams)
|
||||
>>>>>>> main
|
||||
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { branding: { enabled: false } },
|
||||
})
|
||||
return renderWithNuqs(<SystemFeaturesWrapper><List pageType={pageType} /></SystemFeaturesWrapper>, { searchParams })
|
||||
}
|
||||
|
||||
const openTypeFilter = () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^app\.(studio\.filters\.types|types\.)/ }))
|
||||
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
|
||||
}
|
||||
|
||||
type AppListInfiniteOptions = {
|
||||
@ -294,7 +264,7 @@ describe('List', () => {
|
||||
mockServiceState.isFetchingNextPage = false
|
||||
mockQueryState.category = 'all'
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.creatorID = ''
|
||||
mockQueryState.isCreatedByMe = false
|
||||
mockUseWorkflowOnlineUsers.mockClear()
|
||||
intersectionCallback = null
|
||||
localStorage.clear()
|
||||
@ -303,12 +273,11 @@ describe('List', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app type dropdown with all app types', () => {
|
||||
it('should render tab slider with all app types', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
|
||||
@ -328,21 +297,9 @@ describe('List', () => {
|
||||
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render creators filter', () => {
|
||||
it('should render created by me checkbox', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render link to snippets on apps page', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('link', { name: 'app.studio.viewSnippets' })).toHaveAttribute('href', '/snippets')
|
||||
})
|
||||
|
||||
it('should not render link to snippets on snippets page', () => {
|
||||
renderList('', 'snippets')
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
@ -377,22 +334,20 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type Filter', () => {
|
||||
it('should update category when workflow type is selected', () => {
|
||||
describe('Tab Navigation', () => {
|
||||
it('should update category when workflow tab is clicked', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.workflow' }))
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
|
||||
expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should update category when all type is selected', () => {
|
||||
it('should update category when all tab is clicked', () => {
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.all' }))
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
|
||||
expect(mockSetCategory).toHaveBeenCalledWith('all')
|
||||
})
|
||||
@ -418,7 +373,10 @@ describe('List', () => {
|
||||
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
const clearButton = document.querySelector('.group')
|
||||
expect(clearButton)!.toBeInTheDocument()
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
})
|
||||
@ -427,7 +385,7 @@ describe('List', () => {
|
||||
describe('App List Query', () => {
|
||||
it('should build paged query input from active filters', () => {
|
||||
mockQueryState.keywords = 'sales'
|
||||
mockQueryState.creatorID = 'creator-1'
|
||||
mockQueryState.isCreatedByMe = true
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
|
||||
renderList()
|
||||
@ -441,7 +399,7 @@ describe('List', () => {
|
||||
limit: 30,
|
||||
name: 'sales',
|
||||
tag_ids: ['tag-1'],
|
||||
creator_id: 'creator-1',
|
||||
is_created_by_me: true,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
})
|
||||
@ -468,19 +426,19 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Creators Filter', () => {
|
||||
it('should render creators filter with correct label', () => {
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render checkbox with correct label', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle creator selection as a single creator filter', () => {
|
||||
it('should handle checkbox change', () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
|
||||
const checkbox = screen.getByRole('checkbox', { name: 'app.showMyCreatedAppsOnly' })
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
|
||||
expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -526,11 +484,11 @@ describe('List', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { unmount } = renderList()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
renderList()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards correctly', () => {
|
||||
@ -543,10 +501,9 @@ describe('List', () => {
|
||||
it('should render with all filter options visible', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -563,10 +520,9 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Type Dropdown', () => {
|
||||
it('should render all app type options', () => {
|
||||
describe('App Type Tabs', () => {
|
||||
it('should render all app type tabs', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
|
||||
@ -576,7 +532,9 @@ describe('List', () => {
|
||||
expect(screen.getByText('app.types.completion'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update category for each app type option click', () => {
|
||||
it('should update category for each app type tab click', () => {
|
||||
renderList()
|
||||
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
|
||||
@ -587,11 +545,8 @@ describe('List', () => {
|
||||
|
||||
for (const { mode, text } of appTypeTexts) {
|
||||
mockSetCategory.mockClear()
|
||||
const { unmount } = renderList()
|
||||
openTypeFilter()
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: text }))
|
||||
fireEvent.click(screen.getByText(text))
|
||||
expect(mockSetCategory).toHaveBeenCalledWith(mode)
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import { parseAsStringLiteral } from 'nuqs'
|
||||
import { AppModes } from '@/types/app'
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
export type { AppListCategory }
|
||||
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
export const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
@ -1,76 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AppListCategory } from './app-type-filter-shared'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { isAppListCategory } from './app-type-filter-shared'
|
||||
|
||||
const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
|
||||
|
||||
type AppTypeFilterProps = {
|
||||
value: AppListCategory
|
||||
onChange: (value: AppListCategory) => void
|
||||
}
|
||||
|
||||
export function AppTypeFilter({
|
||||
value,
|
||||
onChange,
|
||||
}: AppTypeFilterProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = useMemo(() => ([
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
|
||||
]), [t])
|
||||
|
||||
const activeOption = options.find(option => option.value === value)
|
||||
const isSelected = value !== 'all'
|
||||
const triggerLabel = isSelected ? activeOption?.text : t('studio.filters.types', { ns: 'app' })
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
chipClassName,
|
||||
isSelected
|
||||
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
|
||||
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
|
||||
<span className="px-1 text-text-tertiary">{triggerLabel}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={nextValue => isAppListCategory(nextValue) && onChange(nextValue)}>
|
||||
{options.map(option => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
|
||||
<span>{option.text}</span>
|
||||
<DropdownMenuRadioItemIndicator />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@ -1,230 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
|
||||
type CreatorsFilterProps = {
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
}
|
||||
|
||||
type CreatorOption = {
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl: string | null
|
||||
isYou: boolean
|
||||
}
|
||||
|
||||
const baseChipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
|
||||
|
||||
const CreatorsFilter = ({
|
||||
value,
|
||||
onChange,
|
||||
}: CreatorsFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile } = useAppContext()
|
||||
const { data: membersData } = useMembers()
|
||||
const [keywords, setKeywords] = useState('')
|
||||
|
||||
const creatorOptions = useMemo<CreatorOption[]>(() => {
|
||||
const currentUserId = userProfile?.id
|
||||
const members = membersData?.accounts ?? []
|
||||
|
||||
return [...members]
|
||||
.filter(member => member.status !== 'pending')
|
||||
.sort((left, right) => {
|
||||
if (left.id === currentUserId)
|
||||
return -1
|
||||
if (right.id === currentUserId)
|
||||
return 1
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
.map(member => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
avatarUrl: member.avatar_url,
|
||||
isYou: member.id === currentUserId,
|
||||
}))
|
||||
}, [membersData?.accounts, userProfile?.id])
|
||||
|
||||
const filteredCreators = useMemo(() => {
|
||||
const normalizedKeywords = keywords.trim().toLowerCase()
|
||||
if (!normalizedKeywords)
|
||||
return creatorOptions
|
||||
|
||||
return creatorOptions.filter((creator) => {
|
||||
const keyword = normalizedKeywords
|
||||
return creator.name.toLowerCase().includes(keyword)
|
||||
})
|
||||
}, [creatorOptions, keywords])
|
||||
|
||||
const selectedCreators = useMemo(() => {
|
||||
const creatorMap = new Map(creatorOptions.map(creator => [creator.id, creator]))
|
||||
return value
|
||||
.map(id => creatorMap.get(id))
|
||||
.filter((creator): creator is CreatorOption => Boolean(creator))
|
||||
}, [creatorOptions, value])
|
||||
|
||||
const toggleCreator = useCallback((creatorId: string) => {
|
||||
if (value.includes(creatorId)) {
|
||||
onChange(value.filter(id => id !== creatorId))
|
||||
return
|
||||
}
|
||||
|
||||
onChange([...value, creatorId])
|
||||
}, [onChange, value])
|
||||
|
||||
const resetCreators = useCallback(() => {
|
||||
onChange([])
|
||||
setKeywords('')
|
||||
}, [onChange])
|
||||
|
||||
const selectedCount = value.length
|
||||
const selectedAvatarCreators = selectedCreators.slice(0, 3)
|
||||
const isSelected = selectedCount > 0
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
baseChipClassName,
|
||||
isSelected
|
||||
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
|
||||
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
{!isSelected && (
|
||||
<>
|
||||
<span className="px-1 text-text-tertiary">{t('studio.filters.allCreators', { ns: 'app' })}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</>
|
||||
)}
|
||||
{isSelected && (
|
||||
<>
|
||||
<span className="px-1 text-text-tertiary">{t('studio.filters.creators', { ns: 'app' })}</span>
|
||||
<span className="flex items-center pr-1">
|
||||
{selectedAvatarCreators.map((creator, index) => (
|
||||
<Avatar
|
||||
key={creator.id}
|
||||
avatar={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size="xs"
|
||||
className={cn(
|
||||
'border border-components-panel-bg',
|
||||
index > 0 && '-ml-1',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-xs leading-4 font-medium text-text-tertiary">{`+${selectedCount}`}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('studio.filters.reset', { ns: 'app' })}
|
||||
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-xs text-text-quaternary hover:text-text-tertiary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
resetCreators()
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ')
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetCreators()
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill h-3.5 w-3.5" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
|
||||
<div className="flex items-center gap-1 p-2 pb-1">
|
||||
<div className="relative min-w-0 grow">
|
||||
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
|
||||
<Input
|
||||
className={cn('pl-6.5', keywords && 'pr-6.5')}
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
|
||||
/>
|
||||
{!!keywords && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
|
||||
onClick={() => setKeywords('')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-sm px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={resetCreators}
|
||||
>
|
||||
{t('studio.filters.reset', { ns: 'app' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto px-1 pb-1">
|
||||
{filteredCreators.map((creator) => {
|
||||
const checked = value.includes(creator.id)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={creator.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => toggleCreator(creator.id)}
|
||||
>
|
||||
<Checkbox
|
||||
id={creator.id}
|
||||
checked={checked}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="flex min-w-0 grow items-center gap-2 px-1">
|
||||
<Avatar
|
||||
avatar={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size="xs"
|
||||
className="border-[0.5px] border-divider-regular"
|
||||
/>
|
||||
<div className="flex min-w-0 grow items-center justify-between gap-2">
|
||||
<span className="truncate text-sm text-text-secondary">{creator.name}</span>
|
||||
{creator.isYou && (
|
||||
<span className="shrink-0 text-sm text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreatorsFilter
|
||||
@ -17,11 +17,7 @@ const DefaultCards = React.memo(() => {
|
||||
)
|
||||
})
|
||||
|
||||
type EmptyProps = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const Empty = ({ message }: EmptyProps) => {
|
||||
const Empty = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@ -29,7 +25,7 @@ const Empty = ({ message }: EmptyProps) => {
|
||||
<DefaultCards />
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-linear-to-t from-background-body to-transparent">
|
||||
<span className="system-md-medium text-text-tertiary">
|
||||
{message ?? t('newApp.noAppsFound', { ns: 'app' })}
|
||||
{t('newApp.noAppsFound', { ns: 'app' })}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -20,22 +20,22 @@ describe('useAppsQueryState', () => {
|
||||
expect(result.current.query).toEqual({
|
||||
category: 'all',
|
||||
keywords: '',
|
||||
creatorID: '',
|
||||
isCreatedByMe: false,
|
||||
})
|
||||
expect(typeof result.current.setCategory).toBe('function')
|
||||
expect(typeof result.current.setKeywords).toBe('function')
|
||||
expect(typeof result.current.setCreatorID).toBe('function')
|
||||
expect(typeof result.current.setIsCreatedByMe).toBe('function')
|
||||
})
|
||||
|
||||
it('should parse app list filters from URL', () => {
|
||||
const { result } = renderWithAdapter(
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&creatorID=creator-1',
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true',
|
||||
)
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
category: AppModeEnum.WORKFLOW,
|
||||
keywords: 'search term',
|
||||
creatorID: 'creator-1',
|
||||
isCreatedByMe: true,
|
||||
})
|
||||
})
|
||||
|
||||
@ -115,30 +115,30 @@ describe('useAppsQueryState', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should update creator ID URL state', async () => {
|
||||
it('should update created-by-me URL state', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setCreatorID('creator-1')
|
||||
result.current.setIsCreatedByMe(true)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.creatorID).toBe('creator-1')
|
||||
expect(update.searchParams.get('creatorID')).toBe('creator-1')
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
expect(update.searchParams.get('isCreatedByMe')).toBe('true')
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
it('should remove creatorID from URL when cleared', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?creatorID=creator-1')
|
||||
it('should remove isCreatedByMe from URL when disabled', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
|
||||
|
||||
act(() => {
|
||||
result.current.setCreatorID('')
|
||||
result.current.setIsCreatedByMe(false)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.creatorID).toBe('')
|
||||
expect(update.searchParams.has('creatorID')).toBe(false)
|
||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
||||
expect(update.searchParams.has('isCreatedByMe')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,16 +1,26 @@
|
||||
import type { AppListCategory } from '../app-type-filter-shared'
|
||||
import { debounce, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { debounce, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { parseAsAppListCategory } from '../app-type-filter-shared'
|
||||
import { AppModes } from '@/types/app'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
export const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
const appListQueryParsers = {
|
||||
category: parseAsAppListCategory,
|
||||
category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' }),
|
||||
keywords: parseAsString.withDefault('').withOptions({
|
||||
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
|
||||
}),
|
||||
creatorID: parseAsString
|
||||
.withDefault('')
|
||||
isCreatedByMe: parseAsBoolean
|
||||
.withDefault(false)
|
||||
.withOptions({ history: 'push' }),
|
||||
}
|
||||
|
||||
@ -25,14 +35,14 @@ export function useAppsQueryState() {
|
||||
setQuery({ keywords })
|
||||
}, [setQuery])
|
||||
|
||||
const setCreatorID = useCallback((creatorID: string) => {
|
||||
setQuery({ creatorID })
|
||||
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
|
||||
setQuery({ isCreatedByMe })
|
||||
}, [setQuery])
|
||||
|
||||
return useMemo(() => ({
|
||||
query,
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setCreatorID,
|
||||
}), [query, setCategory, setKeywords, setCreatorID])
|
||||
setIsCreatedByMe,
|
||||
}), [query, setCategory, setKeywords, setIsCreatedByMe])
|
||||
}
|
||||
|
||||
@ -15,29 +15,19 @@ import { fetchAppDetail } from '@/service/explore'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import List from './list'
|
||||
|
||||
export type StudioPageType = 'apps' | 'snippets'
|
||||
|
||||
type AppsProps = {
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
|
||||
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
|
||||
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
|
||||
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
|
||||
const ImportFromMarketplaceTemplateModal = dynamic(() => import('./import-from-marketplace-template-modal'), { ssr: false })
|
||||
|
||||
const Apps = ({
|
||||
pageType = 'apps',
|
||||
}: AppsProps) => {
|
||||
const Apps = () => {
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
const { replace } = useRouter()
|
||||
const templateId = searchParams.get('template-id')
|
||||
const templateDismissedRef = useRef(false)
|
||||
|
||||
useDocumentTitle(pageType === 'apps'
|
||||
? t('menus.apps', { ns: 'common' })
|
||||
: t('tabs.snippets', { ns: 'workflow' }))
|
||||
useDocumentTitle(t('menus.apps', { ns: 'common' }))
|
||||
useEducationInit()
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
@ -175,7 +165,7 @@ const Apps = ({
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<List controlRefreshList={controlRefreshList} pageType={pageType} />
|
||||
<List controlRefreshList={controlRefreshList} />
|
||||
{isShowTryAppPanel && (
|
||||
<TryApp
|
||||
appId={currentTryAppParams?.appId || ''}
|
||||
|
||||
@ -1,32 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { StudioPageType } from '.'
|
||||
import type { AppListQuery } from '@/contract/console/apps'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import { AppTypeFilter } from './app-type-filter'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
|
||||
import CreatorsFilter from './creators-filter'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import { useAppsQueryState } from './hooks/use-apps-query-state'
|
||||
import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
|
||||
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
||||
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
|
||||
import NewAppCard from './new-app-card'
|
||||
@ -40,11 +38,9 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
|
||||
type Props = {
|
||||
controlRefreshList?: number
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
const List: FC<Props> = ({
|
||||
controlRefreshList = 0,
|
||||
pageType = 'apps',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
@ -55,10 +51,10 @@ const List: FC<Props> = ({
|
||||
|
||||
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
|
||||
const {
|
||||
query: { category, keywords, creatorID },
|
||||
query: { category, keywords, isCreatedByMe },
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setCreatorID,
|
||||
setIsCreatedByMe,
|
||||
} = useAppsQueryState()
|
||||
const [tagIDs, setTagIDs] = useState<string[]>([])
|
||||
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
|
||||
@ -94,9 +90,9 @@ const List: FC<Props> = ({
|
||||
limit: 30,
|
||||
name: debouncedKeywords,
|
||||
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
|
||||
...(creatorID ? { creator_id: creatorID } : {}),
|
||||
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
|
||||
...(category !== 'all' ? { mode: category } : {}),
|
||||
}), [category, creatorID, debouncedKeywords, tagIDs])
|
||||
}), [category, debouncedKeywords, isCreatedByMe, tagIDs])
|
||||
|
||||
const {
|
||||
data,
|
||||
@ -130,6 +126,14 @@ const List: FC<Props> = ({
|
||||
}, [controlRefreshList, refetch])
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const options = [
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line h-[14px] w-[14px]" /> },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
|
||||
@ -168,9 +172,9 @@ const List: FC<Props> = ({
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
|
||||
|
||||
const handleCreatorsChange = useCallback((creatorIDs: string[]) => {
|
||||
setCreatorID(creatorIDs.at(-1) ?? '')
|
||||
}, [setCreatorID])
|
||||
const handleCreatedByMeChange = useCallback((checked: boolean) => {
|
||||
setIsCreatedByMe(checked)
|
||||
}, [setIsCreatedByMe])
|
||||
|
||||
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
|
||||
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
|
||||
@ -203,45 +207,32 @@ const List: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 bg-background-body px-12 pt-7 pb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AppTypeFilter
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
/>
|
||||
<CreatorsFilter
|
||||
value={creatorID ? [creatorID] : []}
|
||||
onChange={handleCreatorsChange}
|
||||
/>
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-7 pb-5">
|
||||
<TabSliderNew
|
||||
value={category}
|
||||
onChange={(nextValue) => {
|
||||
if (isAppListCategory(nextValue))
|
||||
setCategory(nextValue)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="mr-2 flex h-7 items-center space-x-2">
|
||||
<Checkbox checked={isCreatedByMe} onCheckedChange={handleCreatedByMeChange} />
|
||||
<div className="text-sm font-normal text-text-secondary">
|
||||
{t('showMyCreatedAppsOnly', { ns: 'app' })}
|
||||
</div>
|
||||
</label>
|
||||
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
|
||||
<div className="relative w-50">
|
||||
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
|
||||
<Input
|
||||
className={cn('pl-6.5', keywords && 'pr-6.5')}
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
placeholder={t('operation.search', { ns: 'common' })}
|
||||
/>
|
||||
{!!keywords && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
|
||||
onClick={() => setKeywords('')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="w-[200px]"
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onClear={() => setKeywords('')}
|
||||
/>
|
||||
</div>
|
||||
{pageType === 'apps' && (
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary hover:bg-state-base-hover hover:text-text-primary"
|
||||
>
|
||||
{t('studio.viewSnippets', { ns: 'app' })}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
|
||||
@ -269,7 +260,7 @@ const List: FC<Props> = ({
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
/>
|
||||
))
|
||||
: <Empty message={pageType === 'snippets' ? t('tabs.noSnippetsFound', { ns: 'workflow' }) : undefined} />}
|
||||
: <Empty />}
|
||||
{isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
|
||||
19
web/app/components/education-verify-action-recorder.tsx
Normal file
19
web/app/components/education-verify-action-recorder.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||
} from '@/app/education-apply/constants'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
|
||||
export function EducationVerifyActionRecorder() {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('action') === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
|
||||
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
|
||||
}, [searchParams])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Loading from './base/loading'
|
||||
|
||||
export default function RootLoading() {
|
||||
export function FullScreenLoading() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
@ -277,7 +277,6 @@ describe('AccountDropdown', () => {
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('setup_status')
|
||||
expect(mockPush).toHaveBeenCalledWith('/signin')
|
||||
})
|
||||
})
|
||||
|
||||
@ -121,7 +121,6 @@ export default function AppSelector() {
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
resetUser()
|
||||
localStorage.removeItem('setup_status')
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
// To avoid use other account's education notice info
|
||||
|
||||
@ -104,23 +104,12 @@ vi.mock('../../nav', () => ({
|
||||
onCreate,
|
||||
onLoadMore,
|
||||
navigationItems,
|
||||
activeSegment,
|
||||
activeLink,
|
||||
text,
|
||||
}: {
|
||||
onCreate: (state: string) => void
|
||||
onLoadMore?: () => void
|
||||
navigationItems?: Array<{ id: string, name: string, link: string }>
|
||||
activeSegment?: string | string[]
|
||||
activeLink?: { segment: string, text: string, link: string }
|
||||
text?: string
|
||||
}) => (
|
||||
<div data-testid="nav">
|
||||
<div data-testid="nav-text">{text}</div>
|
||||
<div data-testid="nav-active-segment">{JSON.stringify(activeSegment)}</div>
|
||||
{activeLink && (
|
||||
<div data-testid="nav-active-link">{`${activeLink.segment}:${activeLink.text}->${activeLink.link}`}</div>
|
||||
)}
|
||||
<ul data-testid="nav-items">
|
||||
{(navigationItems ?? []).map(item => (
|
||||
<li key={item.id}>{`${item.name} -> ${item.link}`}</li>
|
||||
@ -212,15 +201,6 @@ describe('AppNav', () => {
|
||||
expect(options.getNextPageParam({ has_more: false, page: 3 })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should configure snippets as an active studio child link', () => {
|
||||
setupDefaultMocks()
|
||||
render(<AppNav />)
|
||||
|
||||
expect(screen.getByTestId('nav-text')).toHaveTextContent('menus.apps')
|
||||
expect(screen.getByTestId('nav-active-segment')).toHaveTextContent(JSON.stringify(['apps', 'app', 'snippets']))
|
||||
expect(screen.getByTestId('nav-active-link')).toHaveTextContent('snippets:tabs.snippets->/snippets')
|
||||
})
|
||||
|
||||
it('should build editor links and update app name when app detail changes', async () => {
|
||||
setupDefaultMocks({
|
||||
isEditor: true,
|
||||
|
||||
@ -103,13 +103,8 @@ const AppNav = () => {
|
||||
icon={<RiRobot2Line className="size-4" />}
|
||||
activeIcon={<RiRobot2Fill className="size-4" />}
|
||||
text={t('menus.apps', { ns: 'common' })}
|
||||
activeSegment={['apps', 'app', 'snippets']}
|
||||
activeSegment={['apps', 'app']}
|
||||
link="/apps"
|
||||
activeLink={{
|
||||
segment: 'snippets',
|
||||
text: t('tabs.snippets', { ns: 'workflow' }),
|
||||
link: '/snippets',
|
||||
}}
|
||||
curNav={appDetail}
|
||||
navigationItems={navItems}
|
||||
createText={t('menus.newApp', { ns: 'common' })}
|
||||
|
||||
@ -102,13 +102,6 @@ describe('DatasetNav', () => {
|
||||
icon_info: { icon: 'pipeline' },
|
||||
provider: 'vendor',
|
||||
},
|
||||
{
|
||||
id: 'dataset-5',
|
||||
name: 'Null Icon Dataset',
|
||||
runtime_mode: 'general',
|
||||
icon_info: null,
|
||||
provider: 'vendor',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -148,16 +141,6 @@ describe('DatasetNav', () => {
|
||||
render(<DatasetNav />)
|
||||
expect(screen.getByText('common.menus.datasets')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render current dataset when icon info is null', () => {
|
||||
vi.mocked(useDatasetDetail).mockReturnValue({
|
||||
data: { ...mockDataset, icon_info: null },
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
render(<DatasetNav />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Test Dataset/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation Items logic', () => {
|
||||
@ -171,7 +154,6 @@ describe('DatasetNav', () => {
|
||||
expect(within(menu).getByText('Test Dataset')).toBeInTheDocument()
|
||||
expect(within(menu).getByText('Pipeline Dataset')).toBeInTheDocument()
|
||||
expect(within(menu).getByText('External Dataset')).toBeInTheDocument()
|
||||
expect(within(menu).getByText('Null Icon Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should navigate to correct link when an item is clicked', () => {
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { NavItem } from '../nav/nav-selector'
|
||||
import type { DataSet, IconInfo } from '@/models/datasets'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import {
|
||||
RiBook2Fill,
|
||||
RiBook2Line,
|
||||
} from '@remixicon/react'
|
||||
import { flatten } from 'es-toolkit/compat'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -10,24 +14,6 @@ import { useDatasetDetail, useDatasetList } from '@/service/knowledge/use-datase
|
||||
import { basePath } from '@/utils/var'
|
||||
import Nav from '../nav'
|
||||
|
||||
const DEFAULT_DATASET_ICON: IconInfo = {
|
||||
icon_type: 'emoji',
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
}
|
||||
|
||||
type NullableDatasetIconInfo = Partial<{
|
||||
[Key in keyof IconInfo]: IconInfo[Key] | null
|
||||
}>
|
||||
|
||||
const normalizeDatasetIconInfo = (iconInfo?: NullableDatasetIconInfo | null): IconInfo => ({
|
||||
icon_type: iconInfo?.icon_type ?? DEFAULT_DATASET_ICON.icon_type,
|
||||
icon: iconInfo?.icon ?? DEFAULT_DATASET_ICON.icon,
|
||||
icon_background: iconInfo?.icon_background ?? DEFAULT_DATASET_ICON.icon_background,
|
||||
icon_url: iconInfo?.icon_url ?? DEFAULT_DATASET_ICON.icon_url,
|
||||
})
|
||||
|
||||
const DatasetNav = () => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
@ -47,16 +33,15 @@ const DatasetNav = () => {
|
||||
const curNav = useMemo(() => {
|
||||
if (!currentDataset)
|
||||
return
|
||||
const iconInfo = normalizeDatasetIconInfo(currentDataset.icon_info)
|
||||
return {
|
||||
id: currentDataset.id,
|
||||
name: currentDataset.name,
|
||||
icon: iconInfo.icon,
|
||||
icon_type: iconInfo.icon_type,
|
||||
icon_background: iconInfo.icon_background ?? null,
|
||||
icon_url: iconInfo.icon_url ?? null,
|
||||
icon: currentDataset.icon_info.icon,
|
||||
icon_type: currentDataset.icon_info.icon_type,
|
||||
icon_background: currentDataset.icon_info.icon_background,
|
||||
icon_url: currentDataset.icon_info.icon_url,
|
||||
} as Omit<NavItem, 'link'>
|
||||
}, [currentDataset])
|
||||
}, [currentDataset?.id, currentDataset?.name, currentDataset?.icon_info])
|
||||
|
||||
const getDatasetLink = useCallback((dataset: DataSet) => {
|
||||
const isPipelineUnpublished = dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
|
||||
@ -71,15 +56,14 @@ const DatasetNav = () => {
|
||||
const navigationItems = useMemo(() => {
|
||||
return datasetItems.map((dataset) => {
|
||||
const link = getDatasetLink(dataset)
|
||||
const iconInfo = normalizeDatasetIconInfo(dataset.icon_info)
|
||||
return {
|
||||
id: dataset.id,
|
||||
name: dataset.name,
|
||||
link,
|
||||
icon: iconInfo.icon,
|
||||
icon_type: iconInfo.icon_type,
|
||||
icon_background: iconInfo.icon_background ?? null,
|
||||
icon_url: iconInfo.icon_url ?? null,
|
||||
icon: dataset.icon_info.icon,
|
||||
icon_type: dataset.icon_info.icon_type,
|
||||
icon_background: dataset.icon_info.icon_background,
|
||||
icon_url: dataset.icon_info.icon_url,
|
||||
}
|
||||
}) as NavItem[]
|
||||
}, [datasetItems, getDatasetLink])
|
||||
@ -100,8 +84,8 @@ const DatasetNav = () => {
|
||||
return (
|
||||
<Nav
|
||||
isApp={false}
|
||||
icon={<span className="i-ri-book-2-line size-4" />}
|
||||
activeIcon={<span className="i-ri-book-2-fill size-4" />}
|
||||
icon={<RiBook2Line className="size-4" />}
|
||||
activeIcon={<RiBook2Fill className="size-4" />}
|
||||
text={t('menus.datasets', { ns: 'common' })}
|
||||
activeSegment="datasets"
|
||||
link="/datasets"
|
||||
|
||||
@ -4,6 +4,7 @@ import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { useLocalStorageBoolean } from '@/utils/local-storage'
|
||||
import s from './index.module.css'
|
||||
|
||||
type HeaderWrapperProps = {
|
||||
@ -14,17 +15,18 @@ const HeaderWrapper = ({
|
||||
children,
|
||||
}: HeaderWrapperProps) => {
|
||||
const pathname = usePathname()
|
||||
const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname)
|
||||
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
|
||||
// Check if the current path is a workflow canvas & fullscreen
|
||||
const inWorkflowCanvas = pathname.endsWith('/workflow')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
|
||||
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
|
||||
const storedHideHeader = useLocalStorageBoolean('workflow-canvas-maximize')
|
||||
const [eventHideHeader, setEventHideHeader] = useState<boolean | null>(null)
|
||||
const hideHeader = eventHideHeader ?? storedHideHeader
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === 'workflow-canvas-maximize')
|
||||
setHideHeader(v.payload)
|
||||
setEventHideHeader(v.payload)
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@ -3,19 +3,22 @@ import { useTranslation } from 'react-i18next'
|
||||
import { X } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { NOTICE_I18N } from '@/i18n-config/language'
|
||||
import { setLocalStorageItem, useLocalStorageItem } from '@/utils/local-storage'
|
||||
|
||||
const MaintenanceNotice = () => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLanguage()
|
||||
|
||||
const [showNotice, setShowNotice] = useState(() => localStorage.getItem('hide-maintenance-notice') !== '1')
|
||||
const hiddenNotice = useLocalStorageItem('hide-maintenance-notice') === '1'
|
||||
const [closedInSession, setClosedInSession] = useState(false)
|
||||
const showNotice = !hiddenNotice && !closedInSession
|
||||
const handleJumpNotice = () => {
|
||||
window.open(NOTICE_I18N.href, '_blank')
|
||||
}
|
||||
|
||||
const handleCloseNotice = () => {
|
||||
localStorage.setItem('hide-maintenance-notice', '1')
|
||||
setShowNotice(false)
|
||||
setLocalStorageItem('hide-maintenance-notice', '1')
|
||||
setClosedInSession(true)
|
||||
}
|
||||
|
||||
const titleByLocale: { [key: string]: string } = NOTICE_I18N.title
|
||||
|
||||
@ -123,27 +123,6 @@ describe('Nav Component', () => {
|
||||
expect(screen.getByTestId('active-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render active child link when activeLink matches the current segment', () => {
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
|
||||
|
||||
render(
|
||||
<Nav
|
||||
{...defaultProps}
|
||||
activeSegment={['apps', 'app', 'snippets']}
|
||||
activeLink={{
|
||||
segment: 'snippets',
|
||||
text: 'SNIPPETS',
|
||||
link: '/snippets',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Nav Text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nav Text')).toHaveClass('max-[1024px]:hidden')
|
||||
expect(screen.getByRole('link', { name: 'SNIPPETS' })).toHaveAttribute('href', '/snippets')
|
||||
expect(screen.getByRole('link', { name: 'SNIPPETS' })).not.toHaveClass('max-[1024px]:hidden')
|
||||
})
|
||||
|
||||
it('should not show hover background if not activated', () => {
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('other')
|
||||
const { container } = render(<Nav {...defaultProps} />)
|
||||
@ -169,14 +148,6 @@ describe('Nav Component', () => {
|
||||
expect(mockSetAppDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call setAppDetail from snippets segment', () => {
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
|
||||
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} />)
|
||||
const link = screen.getByRole('link')
|
||||
fireEvent.click(link.firstChild!)
|
||||
expect(mockSetAppDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show ArrowNarrowLeft on hover when curNav is provided and activated', () => {
|
||||
const curNav = navigationItems[0]
|
||||
render(<Nav {...defaultProps} curNav={curNav} />)
|
||||
@ -214,20 +185,19 @@ describe('Nav Component', () => {
|
||||
})
|
||||
|
||||
it('should navigate when an item is selected', async () => {
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
|
||||
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} curNav={curNav} />)
|
||||
render(<Nav {...defaultProps} curNav={curNav} />)
|
||||
const selectorButton = screen.getByRole('button', { name: /Item 1/i })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(selectorButton)
|
||||
})
|
||||
mockSetAppDetail.mockClear()
|
||||
|
||||
const item2 = await screen.findByText('Item 2')
|
||||
await act(async () => {
|
||||
fireEvent.click(item2)
|
||||
})
|
||||
|
||||
expect(mockSetAppDetail).toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith('/item2')
|
||||
})
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Link from '@/next/link'
|
||||
import { useSelectedLayoutSegment } from '@/next/navigation'
|
||||
import NavSelector from './nav-selector'
|
||||
@ -15,11 +16,6 @@ type INavProps = {
|
||||
text: string
|
||||
activeSegment: string | string[]
|
||||
link: string
|
||||
activeLink?: {
|
||||
segment: string
|
||||
text: string
|
||||
link: string
|
||||
}
|
||||
isApp: boolean
|
||||
} & INavSelectorProps
|
||||
|
||||
@ -29,7 +25,6 @@ const Nav = ({
|
||||
text,
|
||||
activeSegment,
|
||||
link,
|
||||
activeLink,
|
||||
curNav,
|
||||
navigationItems,
|
||||
createText,
|
||||
@ -42,11 +37,10 @@ const Nav = ({
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const isActivated = Array.isArray(activeSegment) ? activeSegment.includes(segment!) : segment === activeSegment
|
||||
const shouldShowActiveLink = isActivated && activeLink && segment === activeLink.segment
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
flex h-8 max-w-167.5 shrink-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1024px]:max-w-100
|
||||
flex h-8 max-w-[670px] shrink-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1024px]:max-w-[400px]
|
||||
${isActivated && 'bg-components-main-nav-nav-button-bg-active font-semibold shadow-md'}
|
||||
${!curNav && !isActivated && 'hover:bg-components-main-nav-nav-button-bg-hover'}
|
||||
`}
|
||||
@ -57,8 +51,6 @@ const Nav = ({
|
||||
// Don't clear state if opening in new tab/window
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
|
||||
return
|
||||
if (segment === 'snippets')
|
||||
return
|
||||
setAppDetail()
|
||||
}}
|
||||
className={cn('flex h-7 cursor-pointer items-center rounded-[10px] px-2.5', isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text', curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover')}
|
||||
@ -68,7 +60,7 @@ const Nav = ({
|
||||
<div>
|
||||
{
|
||||
(hovered && curNav)
|
||||
? <span className="i-custom-vender-line-arrows-arrow-narrow-left size-4" />
|
||||
? <ArrowNarrowLeft className="size-4" />
|
||||
: isActivated
|
||||
? activeIcon
|
||||
: icon
|
||||
@ -95,19 +87,6 @@ const Nav = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!curNav && shouldShowActiveLink && (
|
||||
<>
|
||||
<div className="font-light text-divider-deep">/</div>
|
||||
<Link
|
||||
href={activeLink.link}
|
||||
className="hover:bg-components-main-nav-nav-button-bg-active-hover flex h-7 cursor-pointer items-center rounded-[10px] px-2.5 text-components-main-nav-nav-button-text-active"
|
||||
>
|
||||
{activeLink.text}
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
66
web/app/components/oauth-registration-analytics.tsx
Normal file
66
web/app/components/oauth-registration-analytics.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import Cookies from 'js-cookie'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { sendGAEvent } from '@/utils/gtag'
|
||||
import { trackEvent } from './base/amplitude'
|
||||
|
||||
const OAUTH_NEW_USER_PARAM = 'oauth_new_user'
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const removeOAuthNewUserParam = () => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete(OAUTH_NEW_USER_PARAM)
|
||||
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
}
|
||||
|
||||
export function OAuthRegistrationAnalytics() {
|
||||
const searchParams = useSearchParams()
|
||||
const oauthNewUserParam = searchParams.get(OAUTH_NEW_USER_PARAM)
|
||||
const handledParamRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthNewUserParam === null || handledParamRef.current === oauthNewUserParam)
|
||||
return
|
||||
|
||||
handledParamRef.current = oauthNewUserParam
|
||||
const oauthNewUser = oauthNewUserParam === 'true'
|
||||
if (!oauthNewUser) {
|
||||
removeOAuthNewUserParam()
|
||||
return
|
||||
}
|
||||
|
||||
let utmInfo: Record<string, unknown> | null = null
|
||||
const utmInfoStr = Cookies.get('utm_info')
|
||||
if (utmInfoStr) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(utmInfoStr)
|
||||
if (isRecord(parsed))
|
||||
utmInfo = parsed
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse utm_info cookie:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const eventName = utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success'
|
||||
|
||||
trackEvent(eventName, {
|
||||
method: 'oauth',
|
||||
...utmInfo,
|
||||
})
|
||||
|
||||
sendGAEvent(eventName, {
|
||||
method: 'oauth',
|
||||
...utmInfo,
|
||||
})
|
||||
|
||||
Cookies.remove('utm_info')
|
||||
removeOAuthNewUserParam()
|
||||
}, [oauthNewUserParam])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -1,278 +0,0 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import SnippetList from '..'
|
||||
|
||||
const mockUseInfiniteSnippetList = vi.hoisted(() => vi.fn())
|
||||
const mockSetKeywords = vi.hoisted(() => vi.fn())
|
||||
const mockSetTagIDs = vi.hoisted(() => vi.fn())
|
||||
const mockSetCreatorID = vi.hoisted(() => vi.fn())
|
||||
const mockQueryState = vi.hoisted(() => ({
|
||||
tagIDs: [] as string[],
|
||||
keywords: '',
|
||||
creatorID: '',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
useImportSnippetDSLMutation: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useConfirmSnippetImportMutation: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useInfiniteSnippetList: (params: unknown, options: unknown) => mockUseInfiniteSnippetList(params, options),
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-snippets-query-state', () => ({
|
||||
useSnippetsQueryState: () => ({
|
||||
query: mockQueryState,
|
||||
setKeywords: mockSetKeywords,
|
||||
setTagIDs: mockSetTagIDs,
|
||||
setCreatorID: mockSetCreatorID,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
systemFeatures: vi.fn(),
|
||||
},
|
||||
consoleQuery: {
|
||||
tags: {
|
||||
list: {
|
||||
queryOptions: (options: unknown) => options,
|
||||
},
|
||||
},
|
||||
systemFeatures: {
|
||||
queryKey: () => ['console', 'systemFeatures'],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
|
||||
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
isLoadingCurrentWorkspace: false,
|
||||
userProfile: { id: 'creator-1' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({
|
||||
data: {
|
||||
accounts: [
|
||||
{ id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' },
|
||||
{ id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(''),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: () => {
|
||||
return function MockDynamicComponent() {
|
||||
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/tag-management/components/tag-selector', () => ({
|
||||
TagSelector: () => <div data-testid="snippet-card-tags" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockObserve = vi.fn()
|
||||
const mockDisconnect = vi.fn()
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.IntersectionObserver = class MockIntersectionObserver {
|
||||
constructor(_callback: IntersectionObserverCallback) {}
|
||||
|
||||
observe = mockObserve
|
||||
disconnect = mockDisconnect
|
||||
unobserve = vi.fn()
|
||||
root = null
|
||||
rootMargin = ''
|
||||
thresholds = []
|
||||
takeRecords = () => []
|
||||
} as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
|
||||
const mockSnippetListState = {
|
||||
data: {
|
||||
pages: [{
|
||||
data: [
|
||||
{
|
||||
id: 'snippet-1',
|
||||
name: 'Sales Snippet',
|
||||
description: 'Builds a sales follow-up.',
|
||||
type: 'node',
|
||||
is_published: true,
|
||||
use_count: 12,
|
||||
tags: [],
|
||||
created_at: 1704067200,
|
||||
created_by: 'creator-1',
|
||||
updated_at: 1704153600,
|
||||
updated_by: 'creator-2',
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
limit: 30,
|
||||
total: 1,
|
||||
has_more: false,
|
||||
}],
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: false,
|
||||
error: null as Error | null,
|
||||
}
|
||||
|
||||
const renderList = () => {
|
||||
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { branding: { enabled: false } },
|
||||
})
|
||||
|
||||
return renderWithNuqs(
|
||||
<SystemFeaturesWrapper>
|
||||
<SnippetList />
|
||||
</SystemFeaturesWrapper>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SnippetList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockQueryState.tagIDs = []
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.creatorID = ''
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
...mockSnippetListState,
|
||||
refetch: mockRefetch,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the dedicated snippets list layout', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.studio.filters.allCreators')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('workflow.tabs.searchSnippets')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'snippet.create' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Sales Snippet/ })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
|
||||
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes creator, tag, and search filters to the snippets list query', () => {
|
||||
mockQueryState.tagIDs = ['tag-1', 'tag-2']
|
||||
mockQueryState.keywords = 'sales'
|
||||
mockQueryState.creatorID = 'creator-1'
|
||||
|
||||
renderList()
|
||||
|
||||
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword: 'sales',
|
||||
tag_ids: ['tag-1', 'tag-2'],
|
||||
creator_id: 'creator-1',
|
||||
}, {
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the search query state from the search input', () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'summary' } })
|
||||
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('summary')
|
||||
})
|
||||
|
||||
it('clears the search query state', () => {
|
||||
mockQueryState.keywords = 'summary'
|
||||
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('updates the creator query state as a single creator filter', () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
|
||||
|
||||
expect(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
|
||||
})
|
||||
|
||||
it('hides the create button for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'snippet.create' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty state when no snippets are returned', () => {
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
...mockSnippetListState,
|
||||
data: {
|
||||
pages: [{
|
||||
data: [],
|
||||
page: 1,
|
||||
limit: 30,
|
||||
total: 0,
|
||||
has_more: false,
|
||||
}],
|
||||
},
|
||||
refetch: mockRefetch,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
})
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,198 +0,0 @@
|
||||
import type { SnippetListItem } from '@/types/snippet'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import SnippetCard from '../snippet-card'
|
||||
|
||||
const {
|
||||
mockDeleteMutate,
|
||||
mockDownloadBlob,
|
||||
mockExportMutateAsync,
|
||||
mockOnRefresh,
|
||||
mockToastError,
|
||||
mockToastSuccess,
|
||||
mockUpdateMutate,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDeleteMutate: vi.fn(),
|
||||
mockDownloadBlob: vi.fn(),
|
||||
mockExportMutateAsync: vi.fn(),
|
||||
mockOnRefresh: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockUpdateMutate: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({
|
||||
data: {
|
||||
accounts: [
|
||||
{ id: 'creator-id', name: 'Creator', email: 'creator@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
|
||||
{ id: 'updater-id', name: 'Updater', email: 'updater@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: mockDeleteMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: mockExportMutateAsync,
|
||||
}),
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: mockUpdateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/time', () => ({
|
||||
formatTime: () => 'formatted-time',
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: mockDownloadBlob,
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/tag-management/components/tag-selector', () => ({
|
||||
TagSelector: ({ value }: { value: Array<{ name: string }> }) => (
|
||||
<div data-testid="snippet-tags">{value.map(tag => tag.name).join(', ')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createSnippet = (overrides: Partial<SnippetListItem> = {}): SnippetListItem => ({
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'Rewrites rough drafts.',
|
||||
type: 'node',
|
||||
is_published: true,
|
||||
use_count: 19,
|
||||
tags: [],
|
||||
created_at: 1_704_067_200,
|
||||
created_by: 'creator-id',
|
||||
updated_at: 1_704_153_600,
|
||||
updated_by: 'updater-id',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SnippetCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render updater name and updated time from member data', () => {
|
||||
render(<SnippetCard snippet={createSnippet()} />)
|
||||
|
||||
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
|
||||
expect(screen.getByText('Updater')).toBeInTheDocument()
|
||||
expect(screen.getByText('formatted-time')).toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.usageCount:{"count":19}')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to creator name when updater is unavailable', () => {
|
||||
render(<SnippetCard snippet={createSnippet({ updated_by: 'missing-user' })} />)
|
||||
|
||||
expect(screen.getByText('Creator')).toBeInTheDocument()
|
||||
expect(screen.getByText('formatted-time')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render draft status for unpublished snippets', () => {
|
||||
render(<SnippetCard snippet={createSnippet({ is_published: false })} />)
|
||||
|
||||
expect(screen.queryByText('snippet.draft')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render supported operations only', async () => {
|
||||
render(<SnippetCard snippet={createSnippet()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
|
||||
expect(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('menuitem', { name: /duplicate/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should export a snippet from the operations menu', async () => {
|
||||
mockExportMutateAsync.mockResolvedValue('snippet-yaml')
|
||||
|
||||
render(<SnippetCard snippet={createSnippet()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.exportSnippet' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: 'snippet-1' })
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fileName: 'Tone Rewriter.yml',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should update snippet info from the operations menu', async () => {
|
||||
mockUpdateMutate.mockImplementation((_payload, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetCard snippet={createSnippet()} onRefresh={mockOnRefresh} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
|
||||
target: { value: 'Updated Snippet' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-1' },
|
||||
body: {
|
||||
name: 'Updated Snippet',
|
||||
description: 'Rewrites rough drafts.',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockOnRefresh).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete a snippet from the operations menu', async () => {
|
||||
mockDeleteMutate.mockImplementation((_payload, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetCard snippet={createSnippet()} onRefresh={mockOnRefresh} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.deleteSnippet' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-1' },
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockOnRefresh).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,111 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import SnippetCreateButton from '../snippet-create-button'
|
||||
|
||||
const { mockPush, mockCreateMutate, mockImportMutateAsync, mockConfirmImportMutateAsync, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockCreateMutate: vi.fn(),
|
||||
mockImportMutateAsync: vi.fn(),
|
||||
mockConfirmImportMutateAsync: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutate: mockCreateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useImportSnippetDSLMutation: () => ({
|
||||
mutateAsync: mockImportMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useConfirmSnippetImportMutation: () => ({
|
||||
mutateAsync: mockConfirmImportMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('SnippetCreateButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should open the create dialog and create a snippet from the modal', async () => {
|
||||
mockCreateMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => {
|
||||
options?.onSuccess?.({ id: 'snippet-123' })
|
||||
})
|
||||
|
||||
render(<SnippetCreateButton />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.create' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.createFromBlank' }))
|
||||
expect(screen.getByText('workflow.snippet.createDialogTitle')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
|
||||
target: { value: 'My Snippet' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), {
|
||||
target: { value: 'Useful snippet description' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
|
||||
|
||||
expect(mockCreateMutate).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'My Snippet',
|
||||
description: 'Useful snippet description',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
|
||||
})
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
|
||||
})
|
||||
|
||||
it('should import a snippet from a DSL URL', async () => {
|
||||
mockImportMutateAsync.mockResolvedValue({
|
||||
id: 'import-1',
|
||||
status: 'completed',
|
||||
snippet_id: 'snippet-imported',
|
||||
error: '',
|
||||
})
|
||||
|
||||
render(<SnippetCreateButton />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.create' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.importDSLFile' }))
|
||||
expect(screen.getByText('snippet.importDialogTitle')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.importFromDSLUrl' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('snippet.importFromDSLUrlPlaceholder'), {
|
||||
target: { value: 'https://example.com/snippet.yml' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockImportMutateAsync).toHaveBeenCalledWith({
|
||||
mode: 'yaml-url',
|
||||
yamlContent: undefined,
|
||||
yamlUrl: 'https://example.com/snippet.yml',
|
||||
})
|
||||
})
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.importSuccess')
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-imported/orchestrate')
|
||||
})
|
||||
})
|
||||
@ -1,259 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetListItem } from '@/types/snippet'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { TagSelector } from '@/features/tag-management/components/tag-selector'
|
||||
import Link from '@/next/link'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { formatTime } from '@/utils/time'
|
||||
|
||||
type Props = {
|
||||
snippet: SnippetListItem
|
||||
onOpenTagManagement?: () => void
|
||||
onRefresh?: () => void
|
||||
onTagsChange?: () => void
|
||||
}
|
||||
|
||||
const SnippetCard = ({
|
||||
snippet,
|
||||
onOpenTagManagement = () => {},
|
||||
onRefresh,
|
||||
onTagsChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { t: tCommon } = useTranslation()
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { data: membersData } = useMembers()
|
||||
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const updateSnippetMutation = useUpdateSnippetMutation()
|
||||
const exportSnippetMutation = useExportSnippetMutation()
|
||||
const deleteSnippetMutation = useDeleteSnippetMutation()
|
||||
|
||||
const memberNameById = useMemo(() => {
|
||||
return new Map((membersData?.accounts ?? []).map(member => [member.id, member.name]))
|
||||
}, [membersData?.accounts])
|
||||
|
||||
const updatedByName = memberNameById.get(snippet.updated_by)
|
||||
|| memberNameById.get(snippet.created_by)
|
||||
|| t('unknownUser')
|
||||
|
||||
const updatedAt = snippet.updated_at || snippet.created_at
|
||||
const updatedAtText = formatTime({
|
||||
date: (updatedAt > 1_000_000_000_000 ? updatedAt : updatedAt * 1000),
|
||||
dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`,
|
||||
})
|
||||
const initialValue = useMemo(() => ({
|
||||
name: snippet.name,
|
||||
description: snippet.description,
|
||||
}), [snippet.description, snippet.name])
|
||||
|
||||
const handleOpenEditDialog = () => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleExportSnippet = async () => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
try {
|
||||
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
|
||||
}
|
||||
catch {
|
||||
toast.error(t('exportFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSnippet = () => {
|
||||
deleteSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('deleted'))
|
||||
setIsDeleteDialogOpen(false)
|
||||
onRefresh?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateSnippet = ({ name, description }: {
|
||||
name: string
|
||||
description: string
|
||||
}) => {
|
||||
updateSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('editDone'))
|
||||
setIsEditDialogOpen(false)
|
||||
onRefresh?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('editFailed'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<article className="group relative col-span-1 inline-flex h-40 w-full cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg">
|
||||
<Link href={`/snippets/${snippet.id}/orchestrate`} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-16.5 shrink-0 grow-0 flex-col justify-center px-3.5 pt-3.5 pb-3">
|
||||
<div className="flex items-center text-sm/5 font-semibold text-text-secondary">
|
||||
<div className="truncate" title={snippet.name}>{snippet.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-2xs leading-4.5 font-medium text-text-tertiary">
|
||||
<div className="truncate" title={updatedByName}>{updatedByName}</div>
|
||||
<div>·</div>
|
||||
<div className="truncate" title={updatedAtText}>{updatedAtText}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-22.5 px-3.5 text-xs leading-normal text-text-tertiary">
|
||||
<div className="line-clamp-2" title={snippet.description}>
|
||||
{snippet.description}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-1.5 pb-1.5 pl-3.5">
|
||||
<div
|
||||
className="flex w-0 grow items-center gap-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="mr-10.25 min-w-0 grow overflow-hidden">
|
||||
<TagSelector
|
||||
placement="bottom-start"
|
||||
type="snippet"
|
||||
targetId={snippet.id}
|
||||
value={snippet.tags}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isCurrentWorkspaceEditor && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-1/2 right-1.5 flex -translate-y-1/2 items-center transition-opacity',
|
||||
isOperationsMenuOpen
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="mx-1 h-3.5 w-px shrink-0 bg-divider-regular" />
|
||||
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={tCommon('operation.more', { ns: 'common' })}
|
||||
className="flex size-8 items-center justify-center rounded-md border-none bg-transparent p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset data-popup-open:bg-state-base-hover data-popup-open:shadow-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="flex size-8 cursor-pointer items-center justify-center rounded-md">
|
||||
<span className="sr-only">{tCommon('operation.more', { ns: 'common' })}</span>
|
||||
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[216px]"
|
||||
>
|
||||
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenEditDialog}>
|
||||
<span className="system-sm-regular text-text-secondary">{t('menu.editInfo')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2 px-3" onClick={handleExportSnippet}>
|
||||
<span className="system-sm-regular text-text-secondary">{t('menu.exportSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2 px-3"
|
||||
onClick={() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-regular">{t('menu.deleteSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
{isEditDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
initialValue={initialValue}
|
||||
title={t('editDialogTitle')}
|
||||
confirmText={tCommon('operation.save', { ns: 'common' })}
|
||||
isSubmitting={updateSnippetMutation.isPending}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onConfirm={handleUpdateSnippet}
|
||||
/>
|
||||
)}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent className="w-100">
|
||||
<div className="space-y-2 p-6">
|
||||
<AlertDialogTitle className="title-md-semi-bold text-text-primary">
|
||||
{t('deleteConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
|
||||
{t('deleteConfirmContent')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="pt-0">
|
||||
<AlertDialogCancelButton disabled={deleteSnippetMutation.isPending}>
|
||||
{tCommon('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={deleteSnippetMutation.isPending}
|
||||
onClick={handleDeleteSnippet}
|
||||
>
|
||||
{t('menu.deleteSnippet')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetCard
|
||||
@ -1,111 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
||||
import ImportSnippetDSLDialog from '@/app/components/snippets/import-snippet-dsl-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import {
|
||||
useCreateSnippetMutation,
|
||||
} from '@/service/use-snippets'
|
||||
|
||||
const SnippetCreateButton = () => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { push } = useRouter()
|
||||
const createSnippetMutation = useCreateSnippetMutation()
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
|
||||
const handleCreateSnippet = ({
|
||||
name,
|
||||
description,
|
||||
}: {
|
||||
name: string
|
||||
description: string
|
||||
}) => {
|
||||
createSnippetMutation.mutate({
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
},
|
||||
}, {
|
||||
onSuccess: (snippet) => {
|
||||
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
|
||||
setIsCreateDialogOpen(false)
|
||||
push(`/snippets/${snippet.id}/orchestrate`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button disabled={createSnippetMutation.isPending}>
|
||||
<span aria-hidden className="mr-0.5 i-ri-add-line size-4" />
|
||||
<span>{t('create')}</span>
|
||||
<span aria-hidden className="ml-0.5 i-ri-arrow-down-s-line size-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={6}
|
||||
popupClassName="w-[228px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
|
||||
>
|
||||
<div className="px-2 pt-2 pb-1 text-xs leading-4.5 font-medium text-text-tertiary">
|
||||
{t('createFrom')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-2 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false)
|
||||
setIsCreateDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="mr-2 i-custom-vender-line-files-file-plus-01 size-4 shrink-0" />
|
||||
<span>{t('createFromBlank')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center rounded-lg px-2 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false)
|
||||
setIsImportDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="mr-2 i-custom-vender-line-files-file-arrow-01 size-4 shrink-0" />
|
||||
<span>{t('importDSLFile')}</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{isCreateDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isCreateDialogOpen}
|
||||
isSubmitting={createSnippetMutation.isPending}
|
||||
onClose={() => setIsCreateDialogOpen(false)}
|
||||
onConfirm={handleCreateSnippet}
|
||||
/>
|
||||
)}
|
||||
{isImportDialogOpen && (
|
||||
<ImportSnippetDSLDialog
|
||||
isOpen={isImportDialogOpen}
|
||||
onClose={() => setIsImportDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetCreateButton
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user