mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
merge main
This commit is contained in:
commit
89200fc1ce
@ -200,7 +200,7 @@ When assigned to test a directory/path, test **ALL content** within that path:
|
||||
|
||||
- ✅ **Import real project components** directly (including base components and siblings)
|
||||
- ✅ **Only mock**: API services (`@/service/*`), `next/navigation`, complex context providers
|
||||
- ❌ **DO NOT mock** base components (`@/app/components/base/*`)
|
||||
- ❌ **DO NOT mock** base components (`@/app/components/base/*`) or dify-ui primitives (`@langgenius/dify-ui/*`)
|
||||
- ❌ **DO NOT mock** sibling/child components in the same directory
|
||||
|
||||
> See [Test Structure Template](#test-structure-template) for correct import/mock patterns.
|
||||
@ -325,12 +325,12 @@ For more detailed information, refer to:
|
||||
### Reference Examples in Codebase
|
||||
|
||||
- `web/utils/classnames.spec.ts` - Utility function tests
|
||||
- `web/app/components/base/button/index.spec.tsx` - Component tests
|
||||
- `web/app/components/base/radio/__tests__/index.spec.tsx` - Component tests
|
||||
- `web/__mocks__/provider-context.ts` - Mock factory example
|
||||
|
||||
### Project Configuration
|
||||
|
||||
- `web/vitest.config.ts` - Vitest configuration
|
||||
- `web/vite.config.ts` - Vite/Vitest configuration
|
||||
- `web/vitest.setup.ts` - Test environment setup
|
||||
- `web/scripts/analyze-component.js` - Component analysis tool
|
||||
- Modules are not mocked automatically. Global mocks live in `web/vitest.setup.ts` (for example `react-i18next`, `next/image`); mock other modules like `ky` or `mime` locally in test files.
|
||||
|
||||
@ -36,7 +36,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
|
||||
|
||||
### Integration vs Mocking
|
||||
|
||||
- [ ] **DO NOT mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
|
||||
- [ ] **DO NOT mock base components or dify-ui primitives** (base `Loading`, `Input`, `Badge`; dify-ui `Button`, `Tooltip`, `Dialog`, etc.)
|
||||
- [ ] Import real project components instead of mocking
|
||||
- [ ] Only mock: API calls, complex context providers, third-party libs with side effects
|
||||
- [ ] Prefer integration testing when using single spec file
|
||||
@ -73,7 +73,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
|
||||
|
||||
### Mocks
|
||||
|
||||
- [ ] **DO NOT mock base components** (`@/app/components/base/*`)
|
||||
- [ ] **DO NOT mock base components or dify-ui primitives** (`@/app/components/base/*` or `@langgenius/dify-ui/*`)
|
||||
- [ ] `vi.clearAllMocks()` in `beforeEach` (not `afterEach`)
|
||||
- [ ] Shared mock state reset in `beforeEach`
|
||||
- [ ] i18n uses global mock (auto-loaded in `web/vitest.setup.ts`); only override locally for custom translations
|
||||
|
||||
@ -2,27 +2,25 @@
|
||||
|
||||
## ⚠️ Important: What NOT to Mock
|
||||
|
||||
### DO NOT Mock Base Components
|
||||
### DO NOT Mock Base Components or dify-ui Primitives
|
||||
|
||||
**Never mock components from `@/app/components/base/`** such as:
|
||||
**Never mock components from `@/app/components/base/` or from `@langgenius/dify-ui/*`** such as:
|
||||
|
||||
- `Loading`, `Spinner`
|
||||
- `Button`, `Input`, `Select`
|
||||
- `Tooltip`, `Modal`, `Dropdown`
|
||||
- `Icon`, `Badge`, `Tag`
|
||||
- Legacy base (`@/app/components/base/*`): `Loading`, `Spinner`, `Input`, `Badge`, `Tag`
|
||||
- dify-ui primitives (`@langgenius/dify-ui/*`): `Button`, `Tooltip`, `Dialog`, `Popover`, `DropdownMenu`, `ContextMenu`, `Select`, `AlertDialog`, `Toast`
|
||||
|
||||
**Why?**
|
||||
|
||||
- Base components will have their own dedicated tests
|
||||
- These components have their own dedicated tests
|
||||
- Mocking them creates false positives (tests pass but real integration fails)
|
||||
- Using real components tests actual integration behavior
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Don't mock base components
|
||||
// ❌ WRONG: Don't mock base components or dify-ui primitives
|
||||
vi.mock('@/app/components/base/loading', () => () => <div>Loading</div>)
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({ children }: any) => <button>{children}</button>)
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children }: any) => <button>{children}</button> }))
|
||||
|
||||
// ✅ CORRECT: Import and use real base components
|
||||
// ✅ CORRECT: Import and use the real components
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
// They will render normally in tests
|
||||
@ -319,7 +317,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
|
||||
### ✅ DO
|
||||
|
||||
1. **Use real base components** - Import from `@/app/components/base/` directly
|
||||
1. **Use real base components and dify-ui primitives** - Import from `@/app/components/base/` or `@langgenius/dify-ui/*` directly
|
||||
1. **Use real project components** - Prefer importing over mocking
|
||||
1. **Use real Zustand stores** - Set test state via `store.setState()`
|
||||
1. **Reset mocks in `beforeEach`**, not `afterEach`
|
||||
@ -330,7 +328,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
|
||||
### ❌ DON'T
|
||||
|
||||
1. **Don't mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
|
||||
1. **Don't mock base components or dify-ui primitives** (`Loading`, `Input`, `Button`, `Tooltip`, `Dialog`, etc.)
|
||||
1. **Don't mock Zustand store modules** - Use real stores with `setState()`
|
||||
1. Don't mock components you can import directly
|
||||
1. Don't create overly simplified mocks that miss conditional logic
|
||||
@ -342,7 +340,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
```
|
||||
Need to use a component in test?
|
||||
│
|
||||
├─ Is it from @/app/components/base/*?
|
||||
├─ Is it from @/app/components/base/* or @langgenius/dify-ui/*?
|
||||
│ └─ YES → Import real component, DO NOT mock
|
||||
│
|
||||
├─ Is it a project component?
|
||||
|
||||
@ -130,6 +130,7 @@ class AppNamePayload(BaseModel):
|
||||
|
||||
class AppIconPayload(BaseModel):
|
||||
icon: str | None = Field(default=None, description="Icon data")
|
||||
icon_type: IconType | None = Field(default=None, description="Icon type")
|
||||
icon_background: str | None = Field(default=None, description="Icon background color")
|
||||
|
||||
|
||||
@ -765,7 +766,12 @@ class AppIconApi(Resource):
|
||||
args = AppIconPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
app_service = AppService()
|
||||
app_model = app_service.update_app_icon(app_model, args.icon or "", args.icon_background or "")
|
||||
app_model = app_service.update_app_icon(
|
||||
app_model,
|
||||
args.icon or "",
|
||||
args.icon_background or "",
|
||||
args.icon_type,
|
||||
)
|
||||
response_model = AppDetail.model_validate(app_model, from_attributes=True)
|
||||
return response_model.model_dump(mode="json")
|
||||
|
||||
|
||||
@ -55,6 +55,7 @@ from fields.dataset_fields import (
|
||||
)
|
||||
from fields.document_fields import document_status_fields
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from libs.url_utils import normalize_api_base_url
|
||||
from models import ApiToken, Dataset, Document, DocumentSegment, EvaluationRun, EvaluationTargetType, UploadFile
|
||||
from models.dataset import DatasetPermission, DatasetPermissionEnum
|
||||
from models.enums import ApiTokenType, SegmentStatus
|
||||
@ -901,7 +902,8 @@ class DatasetApiBaseUrlApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
return {"api_base_url": (dify_config.SERVICE_API_URL or request.host_url.rstrip("/")) + "/v1"}
|
||||
base = dify_config.SERVICE_API_URL or request.host_url.rstrip("/")
|
||||
return {"api_base_url": normalize_api_base_url(base)}
|
||||
|
||||
|
||||
@console_ns.route("/datasets/retrieval-setting")
|
||||
|
||||
@ -1131,6 +1131,14 @@ class ToolMCPAuthApi(Resource):
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
service = MCPToolManageService(session=session)
|
||||
service.clear_provider_credentials(provider_id=provider_id, tenant_id=tenant_id)
|
||||
parsed = urlparse(server_url)
|
||||
sanitized_url = f"{parsed.scheme}://{parsed.hostname}{parsed.path}"
|
||||
logger.warning(
|
||||
"MCP authorization failed for provider %s (url=%s)",
|
||||
provider_id,
|
||||
sanitized_url,
|
||||
exc_info=True,
|
||||
)
|
||||
raise ValueError(f"Failed to connect to MCP server: {e}") from e
|
||||
|
||||
|
||||
|
||||
@ -303,9 +303,16 @@ class StreamableHTTPTransport:
|
||||
|
||||
if response.status_code == 404:
|
||||
if isinstance(message.root, JSONRPCRequest):
|
||||
error_msg = (
|
||||
f"MCP server URL returned 404 Not Found: {self.url} "
|
||||
"— verify the server URL is correct and the server is running"
|
||||
if is_initialization
|
||||
else "Session terminated by server"
|
||||
)
|
||||
self._send_session_terminated_error(
|
||||
ctx.server_to_client_queue,
|
||||
message.root.id,
|
||||
message=error_msg,
|
||||
)
|
||||
return
|
||||
|
||||
@ -381,12 +388,13 @@ class StreamableHTTPTransport:
|
||||
self,
|
||||
server_to_client_queue: ServerToClientQueue,
|
||||
request_id: RequestId,
|
||||
message: str = "Session terminated by server",
|
||||
):
|
||||
"""Send a session terminated error response."""
|
||||
jsonrpc_error = JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=request_id,
|
||||
error=ErrorData(code=32600, message="Session terminated by server"),
|
||||
error=ErrorData(code=32600, message=message),
|
||||
)
|
||||
session_message = SessionMessage(JSONRPCMessage(jsonrpc_error))
|
||||
server_to_client_queue.put(session_message)
|
||||
|
||||
@ -41,6 +41,10 @@ def safe_json_value(v):
|
||||
return v.hex()
|
||||
elif isinstance(v, memoryview):
|
||||
return v.tobytes().hex()
|
||||
elif isinstance(v, np.integer):
|
||||
return int(v)
|
||||
elif isinstance(v, np.floating):
|
||||
return float(v)
|
||||
elif isinstance(v, np.ndarray):
|
||||
return v.tolist()
|
||||
elif isinstance(v, dict):
|
||||
|
||||
@ -47,23 +47,17 @@ def _cookie_domain() -> str | None:
|
||||
def _real_cookie_name(cookie_name: str) -> str:
|
||||
if is_secure() and _cookie_domain() is None:
|
||||
return "__Host-" + cookie_name
|
||||
else:
|
||||
return cookie_name
|
||||
return cookie_name
|
||||
|
||||
|
||||
def _try_extract_from_header(request: Request) -> str | None:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header:
|
||||
if " " not in auth_header:
|
||||
return None
|
||||
else:
|
||||
auth_scheme, auth_token = auth_header.split(None, 1)
|
||||
auth_scheme = auth_scheme.lower()
|
||||
if auth_scheme != "bearer":
|
||||
return None
|
||||
else:
|
||||
return auth_token
|
||||
return None
|
||||
if not auth_header or " " not in auth_header:
|
||||
return None
|
||||
auth_scheme, auth_token = auth_header.split(None, 1)
|
||||
if auth_scheme.lower() != "bearer":
|
||||
return None
|
||||
return auth_token
|
||||
|
||||
|
||||
def extract_refresh_token(request: Request) -> str | None:
|
||||
@ -90,14 +84,9 @@ def extract_webapp_access_token(request: Request) -> str | None:
|
||||
|
||||
|
||||
def extract_webapp_passport(app_code: str, request: Request) -> str | None:
|
||||
def _try_extract_passport_token_from_cookie(request: Request) -> str | None:
|
||||
return request.cookies.get(_real_cookie_name(COOKIE_NAME_PASSPORT + "-" + app_code))
|
||||
|
||||
def _try_extract_passport_token_from_header(request: Request) -> str | None:
|
||||
return request.headers.get(HEADER_NAME_PASSPORT)
|
||||
|
||||
ret = _try_extract_passport_token_from_cookie(request) or _try_extract_passport_token_from_header(request)
|
||||
return ret
|
||||
return request.cookies.get(_real_cookie_name(COOKIE_NAME_PASSPORT + "-" + app_code)) or request.headers.get(
|
||||
HEADER_NAME_PASSPORT
|
||||
)
|
||||
|
||||
|
||||
def set_access_token_to_cookie(request: Request, response: Response, token: str, samesite: str = "Lax"):
|
||||
@ -209,22 +198,18 @@ def check_csrf_token(request: Request, user_id: str):
|
||||
|
||||
if not csrf_token:
|
||||
_unauthorized()
|
||||
verified = {}
|
||||
try:
|
||||
verified = PassportService().verify(csrf_token)
|
||||
except:
|
||||
except Exception:
|
||||
_unauthorized()
|
||||
raise # unreachable, but helps the type checker see verified is always bound
|
||||
|
||||
if verified.get("sub") != user_id:
|
||||
_unauthorized()
|
||||
|
||||
exp: int | None = verified.get("exp")
|
||||
if not exp:
|
||||
if not exp or exp < int(datetime.now(UTC).timestamp()):
|
||||
_unauthorized()
|
||||
else:
|
||||
time_now = int(datetime.now().timestamp())
|
||||
if exp < time_now:
|
||||
_unauthorized()
|
||||
|
||||
|
||||
def generate_csrf_token(user_id: str) -> str:
|
||||
|
||||
3
api/libs/url_utils.py
Normal file
3
api/libs/url_utils.py
Normal file
@ -0,0 +1,3 @@
|
||||
def normalize_api_base_url(base_url: str) -> str:
|
||||
"""Normalize a base URL to always end with /v1, avoiding double /v1 suffixes."""
|
||||
return base_url.rstrip("/").removesuffix("/v1").rstrip("/") + "/v1"
|
||||
@ -25,6 +25,7 @@ from constants import DEFAULT_FILE_NUMBER_LIMITS
|
||||
from core.tools.signature import sign_tool_file
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from libs.helper import generate_string # type: ignore[import-not-found]
|
||||
from libs.url_utils import normalize_api_base_url
|
||||
from libs.uuid_utils import uuidv7
|
||||
from models.utils.file_input_compat import build_file_from_input_mapping
|
||||
|
||||
@ -446,7 +447,8 @@ class App(Base):
|
||||
|
||||
@property
|
||||
def api_base_url(self) -> str:
|
||||
return (dify_config.SERVICE_API_URL or request.host_url.rstrip("/")) + "/v1"
|
||||
base = dify_config.SERVICE_API_URL or request.host_url.rstrip("/")
|
||||
return normalize_api_base_url(base)
|
||||
|
||||
@property
|
||||
def tenant(self) -> Tenant | None:
|
||||
|
||||
@ -303,17 +303,22 @@ class AppService:
|
||||
|
||||
return app
|
||||
|
||||
def update_app_icon(self, app: App, icon: str, icon_background: str) -> App:
|
||||
def update_app_icon(
|
||||
self, app: App, icon: str, icon_background: str, icon_type: IconType | str | None = None
|
||||
) -> App:
|
||||
"""
|
||||
Update app icon
|
||||
:param app: App instance
|
||||
:param icon: new icon
|
||||
:param icon_background: new icon_background
|
||||
:param icon_type: new icon type
|
||||
:return: App instance
|
||||
"""
|
||||
assert current_user is not None
|
||||
app.icon = icon
|
||||
app.icon_background = icon_background
|
||||
if icon_type is not None:
|
||||
app.icon_type = icon_type if isinstance(icon_type, IconType) else IconType(icon_type)
|
||||
app.updated_by = current_user.id
|
||||
app.updated_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
|
||||
@ -234,6 +234,35 @@ class TestAppEndpoints:
|
||||
}
|
||||
)
|
||||
|
||||
def test_app_icon_post_should_forward_icon_type(self, app, monkeypatch):
|
||||
api = app_module.AppIconApi()
|
||||
method = _unwrap(api.post)
|
||||
payload = {
|
||||
"icon": "https://example.com/icon.png",
|
||||
"icon_type": "image",
|
||||
"icon_background": "#FFFFFF",
|
||||
}
|
||||
app_service = MagicMock()
|
||||
app_service.update_app_icon.return_value = SimpleNamespace()
|
||||
response_model = MagicMock()
|
||||
response_model.model_dump.return_value = {"id": "app-1"}
|
||||
|
||||
monkeypatch.setattr(app_module, "AppService", lambda: app_service)
|
||||
monkeypatch.setattr(app_module.AppDetail, "model_validate", MagicMock(return_value=response_model))
|
||||
|
||||
with (
|
||||
app.test_request_context("/console/api/apps/app-1/icon", method="POST", json=payload),
|
||||
patch.object(type(console_ns), "payload", payload),
|
||||
):
|
||||
response = method(app_model=SimpleNamespace())
|
||||
|
||||
assert response == {"id": "app-1"}
|
||||
assert app_service.update_app_icon.call_args.args[1:] == (
|
||||
payload["icon"],
|
||||
payload["icon_background"],
|
||||
app_module.IconType.IMAGE,
|
||||
)
|
||||
|
||||
|
||||
class TestOpsTraceEndpoints:
|
||||
@pytest.fixture
|
||||
|
||||
@ -658,15 +658,17 @@ class TestAppService:
|
||||
# Update app icon
|
||||
new_icon = "🌟"
|
||||
new_icon_background = "#FFD93D"
|
||||
new_icon_type = "image"
|
||||
mock_current_user = create_autospec(Account, instance=True)
|
||||
mock_current_user.id = account.id
|
||||
mock_current_user.current_tenant_id = account.current_tenant_id
|
||||
|
||||
with patch("services.app_service.current_user", mock_current_user):
|
||||
updated_app = app_service.update_app_icon(app, new_icon, new_icon_background)
|
||||
updated_app = app_service.update_app_icon(app, new_icon, new_icon_background, new_icon_type)
|
||||
|
||||
assert updated_app.icon == new_icon
|
||||
assert updated_app.icon_background == new_icon_background
|
||||
assert str(updated_app.icon_type).lower() == new_icon_type
|
||||
assert updated_app.updated_by == account.id
|
||||
|
||||
# Verify other fields remain unchanged
|
||||
|
||||
@ -1772,6 +1772,21 @@ class TestDatasetApiBaseUrlApi:
|
||||
|
||||
assert response["api_base_url"] == "http://localhost:5000/v1"
|
||||
|
||||
def test_get_api_base_url_no_double_v1(self, app):
|
||||
api = DatasetApiBaseUrlApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.datasets.dify_config.SERVICE_API_URL",
|
||||
"https://example.com/v1",
|
||||
),
|
||||
):
|
||||
response = method(api)
|
||||
|
||||
assert response["api_base_url"] == "https://example.com/v1"
|
||||
|
||||
|
||||
class TestDatasetRetrievalSettingApi:
|
||||
def test_get_success(self, app):
|
||||
|
||||
@ -971,6 +971,23 @@ class TestHandlePostRequestNew:
|
||||
assert isinstance(item, SessionMessage)
|
||||
assert isinstance(item.message.root, JSONRPCError)
|
||||
assert item.message.root.id == 77
|
||||
assert item.message.root.error.message == "Session terminated by server"
|
||||
|
||||
def test_404_on_initialization_includes_url_in_error(self):
|
||||
t = _new_transport(url="http://example.com/mcp/server/abc123/mcp")
|
||||
q: queue.Queue = queue.Queue()
|
||||
msg = _make_request_msg("initialize", 1)
|
||||
ctx = self._make_ctx(t, q, message=msg)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
ctx.client.stream = self._stream_ctx(mock_resp)
|
||||
t._handle_post_request(ctx)
|
||||
item = q.get_nowait()
|
||||
assert isinstance(item, SessionMessage)
|
||||
assert isinstance(item.message.root, JSONRPCError)
|
||||
assert item.message.root.error.code == 32600
|
||||
assert "404 Not Found" in item.message.root.error.message
|
||||
assert "http://example.com/mcp/server/abc123/mcp" in item.message.root.error.message
|
||||
|
||||
def test_404_for_notification_no_error_sent(self):
|
||||
t = _new_transport()
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
},
|
||||
"web/__tests__/embedded-user-id-store.test.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/__tests__/goto-anything/command-selector.test.tsx": {
|
||||
@ -1182,14 +1182,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/features/new-feature-panel/feature-bar.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -6081,9 +6073,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/run/node.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
@ -6412,11 +6401,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/context/global-public-context.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/context/hooks/use-trigger-events-limit-modal.ts": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 3
|
||||
|
||||
105
packages/dify-ui/README.md
Normal file
105
packages/dify-ui/README.md
Normal file
@ -0,0 +1,105 @@
|
||||
# @langgenius/dify-ui
|
||||
|
||||
Shared UI primitives, design tokens, Tailwind preset, and the `cn()` utility consumed by Dify's `web/` app.
|
||||
|
||||
The primitives are thin, opinionated wrappers around [Base UI] headless components, styled with `cva` + `cn` and Dify design tokens.
|
||||
|
||||
> `private: true` — this package is consumed by `web/` via the pnpm workspace and is not published to npm. Treat the API as internal to Dify, but stable within the workspace.
|
||||
|
||||
## Installation
|
||||
|
||||
Already wired as a workspace dependency in `web/package.json`. Nothing to install.
|
||||
|
||||
For a new workspace consumer, add:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"dependencies": {
|
||||
"@langgenius/dify-ui": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
Always import from a **subpath export** — there is no barrel:
|
||||
|
||||
```ts
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import '@langgenius/dify-ui/styles.css' // once, in the app root
|
||||
```
|
||||
|
||||
Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported — it keeps tree-shaking trivial and makes Storybook / test coverage attribution per-primitive.
|
||||
|
||||
## Primitives
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
|
||||
| Overlay | `./alert-dialog`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Form | `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
|
||||
|
||||
Utilities:
|
||||
|
||||
- `./cn` — `clsx` + `tailwind-merge` wrapper. Use this for conditional class composition.
|
||||
- `./tailwind-preset` — Tailwind v4 preset with Dify tokens. Apps extend it from their own `tailwind.config.ts`.
|
||||
- `./styles.css` — the one CSS entry that ships the design tokens, theme variables, and base reset. Import it once from the app root.
|
||||
|
||||
## Overlay & portal contract
|
||||
|
||||
All overlay primitives (`dialog`, `alert-dialog`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually.
|
||||
|
||||
### Root isolation requirement
|
||||
|
||||
The host app **must** establish an isolated stacking context at its root so the portalled overlay layer is not clipped or re-ordered by ancestor `transform` / `filter` / `contain` styles. In the Dify web app this is done in `web/app/layout.tsx`:
|
||||
|
||||
```tsx
|
||||
<body>
|
||||
<div className="isolate h-full">{children}</div>
|
||||
</body>
|
||||
```
|
||||
|
||||
Equivalent: any root element with `isolation: isolate` in CSS. Without it, overlays can be visually clipped on Safari when a descendant creates a new stacking context.
|
||||
|
||||
### z-index layering
|
||||
|
||||
Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites.
|
||||
|
||||
| Layer | z-index | Where |
|
||||
| ----------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
|
||||
| Overlays (Dialog, AlertDialog, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
|
||||
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
|
||||
|
||||
Rationale: during Dify's migration from legacy `portal-to-follow-elem` / `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.
|
||||
|
||||
See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history and the remaining legacy allowlist. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`.
|
||||
|
||||
### Rules
|
||||
|
||||
- Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated.
|
||||
- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. Base UI handles focus management, scroll-locking, and dismissal.
|
||||
- When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites.
|
||||
|
||||
## Development
|
||||
|
||||
- `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives.
|
||||
- `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`.
|
||||
- `pnpm -C packages/dify-ui type-check` — `tsc --noEmit` for this package only.
|
||||
|
||||
See `[AGENTS.md](./AGENTS.md)` for:
|
||||
|
||||
- Component authoring rules (one-component-per-folder, `cva` + `cn`, relative imports inside the package, subpath imports from consumers).
|
||||
- Figma `--radius/`* token → Tailwind `rounded-*` class mapping.
|
||||
|
||||
## Not part of this package
|
||||
|
||||
- Application state (`jotai`, `zustand`), data fetching (`ky`, `@tanstack/react-query`, `@orpc/*`), i18n (`next-i18next` / `react-i18next`), and routing (`next`) all live in `web/`. This package has zero dependencies on them and must stay that way so it can eventually be consumed by other apps or extracted.
|
||||
- Business components (chat, workflow, dataset views, etc.). Those belong in `web/app/components/...`.
|
||||
|
||||
[Base UI Portal]: https://base-ui.com/react/overview/quick-start#portals
|
||||
[Base UI]: https://base-ui.com/react
|
||||
[Overlay & portal contract]: #overlay--portal-contract
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
|
||||
// All base/ui/* overlay primitives — z-1002
|
||||
// All @langgenius/dify-ui/* overlay primitives — z-1002
|
||||
// Toast stays one layer above overlays at z-1003.
|
||||
// Overlays share the same z-index; DOM order handles stacking when multiple are open.
|
||||
// This ensures overlays inside a Dialog (e.g. a Tooltip on a dialog button) render
|
||||
|
||||
@ -174,7 +174,7 @@ const StickyListPane = () => (
|
||||
<div className="mt-1 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="system-md-semibold text-text-primary">Operational queue</div>
|
||||
<p className="mt-1 system-xs-regular text-text-secondary">The scrollbar is still the shared base/ui primitive, while the pane adds sticky structure and a viewport mask.</p>
|
||||
<p className="mt-1 system-xs-regular text-text-secondary">The scrollbar is still the shared dify-ui primitive, while the pane adds sticky structure and a viewport mask.</p>
|
||||
</div>
|
||||
<span className="rounded-lg border border-divider-subtle bg-components-panel-bg-alt px-2.5 py-1 system-xs-medium text-text-secondary">
|
||||
24 items
|
||||
|
||||
@ -8,7 +8,7 @@ declare global {
|
||||
var BASE_UI_ANIMATIONS_DISABLED: boolean | undefined
|
||||
}
|
||||
|
||||
describe('base/ui/toast', () => {
|
||||
describe('@langgenius/dify-ui/toast', () => {
|
||||
beforeAll(() => {
|
||||
// Base UI waits for `requestAnimationFrame` + `getAnimations().finished`
|
||||
// before unmounting a toast. Fake timers can't reliably drive that path,
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 751 B |
@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 763 B |
@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 526 B |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 278,
|
||||
"total": 281,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -23,9 +23,10 @@
|
||||
"./custom-vender/chars.json": "./custom-vender/chars.json"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "node ./scripts/generate-collections.mjs"
|
||||
"generate": "tsx ./scripts/generate-collections.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"iconify-import-svg": "catalog:"
|
||||
"iconify-import-svg": "catalog:",
|
||||
"tsx": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,45 +3,62 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { importSvgCollections } from 'iconify-import-svg'
|
||||
|
||||
type IconData = {
|
||||
body: string
|
||||
left?: number
|
||||
top?: number
|
||||
width?: number
|
||||
height?: number
|
||||
rotate?: 0 | 1 | 2 | 3
|
||||
hFlip?: boolean
|
||||
vFlip?: boolean
|
||||
}
|
||||
|
||||
type AliasData = Omit<IconData, 'body'> & {
|
||||
parent: string
|
||||
}
|
||||
|
||||
type ImportedCollection = {
|
||||
icons?: Record<string, IconData>
|
||||
aliases?: Record<string, AliasData>
|
||||
lastModified?: number
|
||||
}
|
||||
|
||||
type ImportedCollections = Record<string, ImportedCollection>
|
||||
|
||||
type CollectionInfo = {
|
||||
prefix: string
|
||||
name: string
|
||||
total: number
|
||||
version: string
|
||||
author: {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
license: {
|
||||
title: string
|
||||
spdx: string
|
||||
url: string
|
||||
}
|
||||
samples: string[]
|
||||
palette: false
|
||||
}
|
||||
|
||||
type PackageJson = {
|
||||
version: string
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const packageDir = path.resolve(__dirname, '..')
|
||||
|
||||
const parseColorOptions = {
|
||||
fallback: () => 'currentColor',
|
||||
}
|
||||
const svgOptimizeConfig = {
|
||||
cleanupSVG: true,
|
||||
deOptimisePaths: true,
|
||||
runSVGO: true,
|
||||
parseColors: parseColorOptions,
|
||||
}
|
||||
|
||||
const customPublicCollections = importSvgCollections({
|
||||
source: path.resolve(packageDir, 'assets/public'),
|
||||
prefix: 'custom-public',
|
||||
ignoreImportErrors: true,
|
||||
...svgOptimizeConfig,
|
||||
})
|
||||
|
||||
const customVenderCollections = importSvgCollections({
|
||||
source: path.resolve(packageDir, 'assets/vender'),
|
||||
prefix: 'custom-vender',
|
||||
ignoreImportErrors: true,
|
||||
...svgOptimizeConfig,
|
||||
})
|
||||
|
||||
const packageJson = JSON.parse(await readFile(path.resolve(packageDir, 'package.json'), 'utf8'))
|
||||
|
||||
const flattenCollections = (collections, prefix) => {
|
||||
const icons = {}
|
||||
const aliases = {}
|
||||
const flattenCollections = (collections: ImportedCollections, prefix: string) => {
|
||||
const icons: Record<string, IconData> = {}
|
||||
const aliases: Record<string, AliasData> = {}
|
||||
let lastModified = 0
|
||||
|
||||
for (const [collectionKey, collection] of Object.entries(collections)) {
|
||||
const segment = collectionKey.slice(prefix.length + 1)
|
||||
const namePrefix = segment
|
||||
? `${segment}-`
|
||||
: ''
|
||||
const namePrefix = segment ? `${segment}-` : ''
|
||||
|
||||
for (const [iconName, iconData] of Object.entries(collection.icons ?? {}))
|
||||
icons[`${namePrefix}${iconName}`] = iconData
|
||||
@ -61,11 +78,38 @@ const flattenCollections = (collections, prefix) => {
|
||||
}
|
||||
}
|
||||
|
||||
const createCollectionInfo = (prefix, name, icons) => ({
|
||||
const customPublicCollections = importSvgCollections({
|
||||
source: path.resolve(packageDir, 'assets/public'),
|
||||
prefix: 'custom-public',
|
||||
ignoreImportErrors: true,
|
||||
cleanupSVG: true,
|
||||
deOptimisePaths: true,
|
||||
runSVGO: true,
|
||||
parseColors: false,
|
||||
}) as ImportedCollections
|
||||
|
||||
const customVenderCollections = importSvgCollections({
|
||||
source: path.resolve(packageDir, 'assets/vender'),
|
||||
prefix: 'custom-vender',
|
||||
ignoreImportErrors: true,
|
||||
cleanupSVG: true,
|
||||
deOptimisePaths: true,
|
||||
runSVGO: false,
|
||||
parseColors: {
|
||||
callback: () => 'currentColor',
|
||||
},
|
||||
}) as ImportedCollections
|
||||
|
||||
const createCollectionInfo = (
|
||||
prefix: string,
|
||||
name: string,
|
||||
icons: Record<string, IconData>,
|
||||
version: string,
|
||||
): CollectionInfo => ({
|
||||
prefix,
|
||||
name,
|
||||
total: Object.keys(icons).length,
|
||||
version: packageJson.version,
|
||||
version,
|
||||
author: {
|
||||
name: 'LangGenius, Inc.',
|
||||
url: 'https://github.com/langgenius/dify',
|
||||
@ -79,7 +123,7 @@ const createCollectionInfo = (prefix, name, icons) => ({
|
||||
palette: false,
|
||||
})
|
||||
|
||||
const createIndexMjs = () => `import icons from './icons.json' with { type: 'json' }
|
||||
const createIndexMjs = (): string => `import icons from './icons.json' with { type: 'json' }
|
||||
import info from './info.json' with { type: 'json' }
|
||||
import metadata from './metadata.json' with { type: 'json' }
|
||||
import chars from './chars.json' with { type: 'json' }
|
||||
@ -87,7 +131,7 @@ import chars from './chars.json' with { type: 'json' }
|
||||
export { icons, info, metadata, chars }
|
||||
`
|
||||
|
||||
const createIndexJs = () => `'use strict'
|
||||
const createIndexJs = (): string => `'use strict'
|
||||
|
||||
const icons = require('./icons.json')
|
||||
const info = require('./info.json')
|
||||
@ -97,7 +141,7 @@ const chars = require('./chars.json')
|
||||
module.exports = { icons, info, metadata, chars }
|
||||
`
|
||||
|
||||
const createIndexTypes = () => `export interface IconifyJSON {
|
||||
const createIndexTypes = (): string => `export interface IconifyJSON {
|
||||
prefix: string
|
||||
icons: Record<string, IconifyIcon>
|
||||
aliases?: Record<string, IconifyAlias>
|
||||
@ -153,9 +197,14 @@ export declare const metadata: IconifyMetaData
|
||||
export declare const chars: IconifyChars
|
||||
`
|
||||
|
||||
const writeCollectionPackage = async (directoryName, collection, name) => {
|
||||
const writeCollectionPackage = async (
|
||||
directoryName: string,
|
||||
collection: ReturnType<typeof flattenCollections>,
|
||||
name: string,
|
||||
version: string,
|
||||
): Promise<void> => {
|
||||
const targetDir = path.resolve(packageDir, directoryName)
|
||||
const info = createCollectionInfo(collection.prefix, name, collection.icons)
|
||||
const info = createCollectionInfo(collection.prefix, name, collection.icons, version)
|
||||
|
||||
await mkdir(targetDir, { recursive: true })
|
||||
await writeFile(path.resolve(targetDir, 'icons.json'), `${JSON.stringify(collection, null, 2)}\n`)
|
||||
@ -167,12 +216,32 @@ const writeCollectionPackage = async (directoryName, collection, name) => {
|
||||
await writeFile(path.resolve(targetDir, 'index.d.ts'), `${createIndexTypes()}\n`)
|
||||
}
|
||||
|
||||
const mergedCustomPublicCollection = flattenCollections(customPublicCollections, 'custom-public')
|
||||
const mergedCustomVenderCollection = flattenCollections(customVenderCollections, 'custom-vender')
|
||||
async function main(): Promise<void> {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.resolve(packageDir, 'package.json'), 'utf8'),
|
||||
) as PackageJson
|
||||
const customPublicCollection = flattenCollections(customPublicCollections, 'custom-public')
|
||||
const customVenderCollection = flattenCollections(customVenderCollections, 'custom-vender')
|
||||
|
||||
await rm(path.resolve(packageDir, 'src'), { recursive: true, force: true })
|
||||
await rm(path.resolve(packageDir, 'custom-public'), { recursive: true, force: true })
|
||||
await rm(path.resolve(packageDir, 'custom-vender'), { recursive: true, force: true })
|
||||
await rm(path.resolve(packageDir, 'src'), { recursive: true, force: true })
|
||||
await rm(path.resolve(packageDir, 'custom-public'), { recursive: true, force: true })
|
||||
await rm(path.resolve(packageDir, 'custom-vender'), { recursive: true, force: true })
|
||||
|
||||
await writeCollectionPackage('custom-public', mergedCustomPublicCollection, 'Dify Custom Public')
|
||||
await writeCollectionPackage('custom-vender', mergedCustomVenderCollection, 'Dify Custom Vender')
|
||||
await writeCollectionPackage(
|
||||
'custom-public',
|
||||
customPublicCollection,
|
||||
'Dify Custom Public',
|
||||
packageJson.version,
|
||||
)
|
||||
await writeCollectionPackage(
|
||||
'custom-vender',
|
||||
customVenderCollection,
|
||||
'Dify Custom Vender',
|
||||
packageJson.version,
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@ -352,8 +352,8 @@ catalogs:
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
iconify-import-svg:
|
||||
specifier: 0.1.2
|
||||
version: 0.1.2
|
||||
specifier: 0.2.0
|
||||
version: 0.2.0
|
||||
immer:
|
||||
specifier: 11.1.4
|
||||
version: 11.1.4
|
||||
@ -718,7 +718,10 @@ importers:
|
||||
devDependencies:
|
||||
iconify-import-svg:
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.2
|
||||
version: 0.2.0
|
||||
tsx:
|
||||
specifier: 'catalog:'
|
||||
version: 4.21.0
|
||||
|
||||
packages/migrate-no-unchecked-indexed-access:
|
||||
dependencies:
|
||||
@ -5932,8 +5935,8 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
iconify-import-svg@0.1.2:
|
||||
resolution: {integrity: sha512-8dwxdGK1a7oPDQhLQOPTbx51tpkxYB6HZvf4fxWz2QVYqEtgop0FWE7OXQ+4zqnrTVUpMIGnOsvqIHtPBK9Isw==}
|
||||
iconify-import-svg@0.2.0:
|
||||
resolution: {integrity: sha512-NFuDyiYRKLSNvbiUnR4627DF4QjQR+bC+n+Nh0lcMnKXv9MCwzikOcdzqITU1yFfRacc6S6PeElc2H5l+35T1Q==}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
@ -13329,7 +13332,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 6.0.2
|
||||
|
||||
iconify-import-svg@0.1.2:
|
||||
iconify-import-svg@0.2.0:
|
||||
dependencies:
|
||||
'@iconify/tools': 4.2.0
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
@ -163,7 +163,7 @@ catalog:
|
||||
html-to-image: 1.11.13
|
||||
i18next: 26.0.4
|
||||
i18next-resources-to-backend: 1.2.1
|
||||
iconify-import-svg: 0.1.2
|
||||
iconify-import-svg: 0.2.0
|
||||
immer: 11.1.4
|
||||
jotai: 2.19.1
|
||||
js-audio-recorder: 1.0.7
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
|
||||
## Overlay Components (Mandatory)
|
||||
|
||||
- `./docs/overlay-migration.md` is the source of truth for overlay-related work.
|
||||
- In new or modified code, use only overlay primitives from `@/app/components/base/ui/*`.
|
||||
- `../packages/dify-ui/README.md` is the permanent contract for overlay primitives, portals, root `isolation: isolate`, and the `z-1002` / `z-1003` layering.
|
||||
- `./docs/overlay-migration.md` is the source of truth for the ongoing migration (deprecated import paths, allowlist, coexistence rules).
|
||||
- In new or modified code, use only overlay primitives from `@langgenius/dify-ui/*`.
|
||||
- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding).
|
||||
|
||||
## Query & Mutation (Mandatory)
|
||||
|
||||
@ -165,7 +165,7 @@ The Dify community can be found on [Discord community], where you can ask questi
|
||||
[Storybook]: https://storybook.js.org
|
||||
[Vite+]: https://viteplus.dev
|
||||
[Vitest]: https://vitest.dev
|
||||
[index.spec.tsx]: ./app/components/base/button/index.spec.tsx
|
||||
[index.spec.tsx]: ./app/components/base/radio/__tests__/index.spec.tsx
|
||||
[pnpm]: https://pnpm.io
|
||||
[vinext]: https://github.com/cloudflare/vinext
|
||||
[web/docs/test.md]: ./docs/test.md
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import AppPublisher from '@/app/components/app/app-publisher'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@ -24,27 +24,15 @@ let mockAppDetail: {
|
||||
}
|
||||
} | null = null
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
const renderWithQueryClient = (ui: React.ReactElement) =>
|
||||
renderWithSystemFeatures(ui, {
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
const queryClient = createTestQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
@ -58,16 +46,6 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (value: number) => `ago:${value}`,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import AppPublisher from '@/app/components/app/app-publisher'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@ -28,27 +28,15 @@ let mockAppDetail: {
|
||||
}
|
||||
} | null = null
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
const renderWithQueryClient = (ui: React.ReactElement) =>
|
||||
renderWithSystemFeatures(ui, {
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
const queryClient = createTestQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
@ -66,16 +54,6 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (value: number) => `ago:${value}`,
|
||||
|
||||
@ -10,8 +10,9 @@
|
||||
* - Access mode icons
|
||||
*/
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import AppCard from '@/app/components/apps/app-card'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
@ -96,15 +97,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = { systemFeatures: mockSystemFeatures }
|
||||
if (typeof selector === 'function')
|
||||
return selector(state)
|
||||
return mockSystemFeatures
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: mockOnPlanInfoChanged,
|
||||
@ -255,7 +247,10 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
const mockOnRefresh = vi.fn()
|
||||
|
||||
const renderAppCard = (app?: Partial<App>) => {
|
||||
return render(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />)
|
||||
return renderWithSystemFeatures(
|
||||
<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />,
|
||||
{ systemFeatures: mockSystemFeatures },
|
||||
)
|
||||
}
|
||||
|
||||
const openOperationsMenu = () => {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
/**
|
||||
* Integration test: App List Browsing Flow
|
||||
*
|
||||
@ -8,11 +9,12 @@
|
||||
*/
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import List from '@/app/components/apps/list'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
@ -64,13 +66,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = { systemFeatures: mockSystemFeatures }
|
||||
return selector ? selector(state) : state
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: vi.fn(),
|
||||
@ -197,11 +192,21 @@ const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse =>
|
||||
total: apps.length,
|
||||
})
|
||||
|
||||
const renderList = (searchParams?: Record<string, string>) => {
|
||||
return renderWithNuqs(
|
||||
<List controlRefreshList={0} />,
|
||||
{ searchParams },
|
||||
const renderListUI = (ui: ReactElement, searchParams?: Record<string, string>) => {
|
||||
const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: mockSystemFeatures,
|
||||
})
|
||||
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsWrapper>
|
||||
<SysWrapper>{children}</SysWrapper>
|
||||
</NuqsWrapper>
|
||||
)
|
||||
return { ...render(ui, { wrapper: Wrapper }), onUrlUpdate }
|
||||
}
|
||||
|
||||
const renderList = (searchParams?: Record<string, string>) => {
|
||||
return renderListUI(<List controlRefreshList={0} />, searchParams)
|
||||
}
|
||||
|
||||
describe('App List Browsing Flow', () => {
|
||||
@ -245,7 +250,7 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
it('should transition from loading to content when data loads', () => {
|
||||
mockIsLoading = true
|
||||
const { rerender } = renderWithNuqs(<List controlRefreshList={0} />)
|
||||
const { rerender } = renderListUI(<List controlRefreshList={0} />)
|
||||
|
||||
const skeletonCards = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletonCards.length).toBeGreaterThan(0)
|
||||
@ -445,7 +450,7 @@ describe('App List Browsing Flow', () => {
|
||||
it('should call refetch when controlRefreshList increments', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
|
||||
const { rerender } = renderWithNuqs(<List controlRefreshList={0} />)
|
||||
const { rerender } = renderListUI(<List controlRefreshList={0} />)
|
||||
|
||||
rerender(<List controlRefreshList={1} />)
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
/**
|
||||
* Integration test: Create App Flow
|
||||
*
|
||||
@ -9,11 +10,12 @@
|
||||
*/
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import List from '@/app/components/apps/list'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
@ -51,13 +53,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = { systemFeatures: mockSystemFeatures }
|
||||
return selector ? selector(state) : state
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: mockOnPlanInfoChanged,
|
||||
@ -251,7 +246,16 @@ const createPage = (apps: App[]): AppListResponse => ({
|
||||
})
|
||||
|
||||
const renderList = () => {
|
||||
return renderWithNuqs(<List controlRefreshList={0} />)
|
||||
const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: mockSystemFeatures,
|
||||
})
|
||||
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper()
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsWrapper>
|
||||
<SysWrapper>{children}</SysWrapper>
|
||||
</NuqsWrapper>
|
||||
)
|
||||
return { ...render(<List controlRefreshList={0} />, { wrapper: Wrapper }), onUrlUpdate }
|
||||
}
|
||||
|
||||
describe('Create App Flow', () => {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, renderHook, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
|
||||
import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
|
||||
import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks'
|
||||
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context'
|
||||
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
@ -19,44 +20,12 @@ vi.mock('@/service/use-share', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
// Store the mock implementation in a way that survives hoisting
|
||||
const mockGetProcessedSystemVariablesFromUrlParams = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/chat/utils', () => ({
|
||||
getProcessedSystemVariablesFromUrlParams: (...args: any[]) => mockGetProcessedSystemVariablesFromUrlParams(...args),
|
||||
}))
|
||||
|
||||
// Use vi.hoisted to define mock state before vi.mock hoisting
|
||||
const { mockGlobalStoreState } = vi.hoisted(() => ({
|
||||
mockGlobalStoreState: {
|
||||
isGlobalPending: false,
|
||||
setIsGlobalPending: vi.fn(),
|
||||
systemFeatures: {},
|
||||
setSystemFeatures: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => {
|
||||
const useGlobalPublicStore = Object.assign(
|
||||
(selector?: (state: typeof mockGlobalStoreState) => any) =>
|
||||
selector ? selector(mockGlobalStoreState) : mockGlobalStoreState,
|
||||
{
|
||||
setState: (updater: any) => {
|
||||
if (typeof updater === 'function')
|
||||
Object.assign(mockGlobalStoreState, updater(mockGlobalStoreState) ?? {})
|
||||
|
||||
else
|
||||
Object.assign(mockGlobalStoreState, updater)
|
||||
},
|
||||
__mockState: mockGlobalStoreState,
|
||||
},
|
||||
)
|
||||
return {
|
||||
useGlobalPublicStore,
|
||||
useIsSystemFeaturesPending: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
const TestConsumer = () => {
|
||||
const embeddedUserId = useWebAppStore(state => state.embeddedUserId)
|
||||
const embeddedConversationId = useWebAppStore(state => state.embeddedConversationId)
|
||||
@ -91,7 +60,6 @@ const initialWebAppStore = (() => {
|
||||
})()
|
||||
|
||||
beforeEach(() => {
|
||||
mockGlobalStoreState.isGlobalPending = false
|
||||
mockGetProcessedSystemVariablesFromUrlParams.mockReset()
|
||||
useWebAppStore.setState(initialWebAppStore, true)
|
||||
})
|
||||
@ -103,7 +71,7 @@ describe('WebAppStoreProvider embedded user id handling', () => {
|
||||
conversation_id: 'conversation-456',
|
||||
})
|
||||
|
||||
render(
|
||||
renderWithSystemFeatures(
|
||||
<WebAppStoreProvider>
|
||||
<TestConsumer />
|
||||
</WebAppStoreProvider>,
|
||||
@ -125,7 +93,7 @@ describe('WebAppStoreProvider embedded user id handling', () => {
|
||||
}))
|
||||
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({})
|
||||
|
||||
render(
|
||||
renderWithSystemFeatures(
|
||||
<WebAppStoreProvider>
|
||||
<TestConsumer />
|
||||
</WebAppStoreProvider>,
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { App } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
|
||||
import AppList from '@/app/components/explore/app-list'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import AccountDropdown from '@/app/components/header/account-dropdown'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
@ -52,20 +52,6 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
systemFeatures: {
|
||||
branding: {
|
||||
enabled: false,
|
||||
workspace_logo: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
return selector ? selector(state) : state
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
@ -108,18 +94,14 @@ vi.mock('@/next/link', () => ({
|
||||
}))
|
||||
|
||||
const renderAccountDropdown = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
return renderWithSystemFeatures(<AccountDropdown />, {
|
||||
systemFeatures: {
|
||||
branding: {
|
||||
enabled: false,
|
||||
workspace_logo: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AccountDropdown />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Header Account Dropdown Flow', () => {
|
||||
|
||||
@ -1,16 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
|
||||
import { InstallationScope } from '@/types/feature'
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Plugin Marketplace to Install Flow', () => {
|
||||
describe('install permission validation pipeline', () => {
|
||||
const systemFeaturesAll = {
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import PluginPage from '@/app/components/plugins/plugin-page'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
|
||||
const mockFetchManifestFromMarketPlace = vi.fn()
|
||||
|
||||
@ -35,17 +37,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useReferenceSettings: () => ({
|
||||
data: {
|
||||
@ -104,13 +95,30 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () =
|
||||
}))
|
||||
|
||||
const renderPluginPage = (searchParams = '') => {
|
||||
return renderWithNuqs(
|
||||
<PluginPage
|
||||
plugins={<div data-testid="plugins-view">plugins view</div>}
|
||||
marketplace={<div data-testid="marketplace-view">marketplace view</div>}
|
||||
/>,
|
||||
{ searchParams },
|
||||
const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: {
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsWrapper>
|
||||
<SysWrapper>{children}</SysWrapper>
|
||||
</NuqsWrapper>
|
||||
)
|
||||
return {
|
||||
...render(
|
||||
<PluginPage
|
||||
plugins={<div data-testid="plugins-view">plugins view</div>}
|
||||
marketplace={<div data-testid="marketplace-view">marketplace view</div>}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
),
|
||||
onUrlUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Plugin Page Shell Flow', () => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import TextGeneration from '@/app/components/share/text-generation'
|
||||
|
||||
const useSearchParamsMock = vi.fn(() => new URLSearchParams())
|
||||
@ -117,7 +118,7 @@ vi.mock('@/service/share', async () => {
|
||||
const mockSystemFeatures = {
|
||||
branding: {
|
||||
enabled: false,
|
||||
workspace_logo: null,
|
||||
workspace_logo: '',
|
||||
},
|
||||
}
|
||||
|
||||
@ -170,11 +171,6 @@ const mockWebAppState = {
|
||||
webAppAccessMode: 'public' as AccessMode,
|
||||
}
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) =>
|
||||
selector({ systemFeatures: mockSystemFeatures }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => selector(mockWebAppState),
|
||||
}))
|
||||
@ -189,7 +185,7 @@ describe('TextGeneration', () => {
|
||||
})
|
||||
|
||||
it('should switch between create, batch, and saved tabs after app state loads', async () => {
|
||||
render(<TextGeneration />)
|
||||
renderWithSystemFeatures(<TextGeneration />, { systemFeatures: mockSystemFeatures })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
|
||||
@ -212,7 +208,7 @@ describe('TextGeneration', () => {
|
||||
})
|
||||
|
||||
it('should wire single-run stop control and clear it when batch execution starts', async () => {
|
||||
render(<TextGeneration />)
|
||||
renderWithSystemFeatures(<TextGeneration />, { systemFeatures: mockSystemFeatures })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import ProviderList from '@/app/components/tools/provider-list'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
|
||||
const mockInvalidateInstalledPluginList = vi.fn()
|
||||
|
||||
@ -12,14 +14,6 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
enable_marketplace: true,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useTags: () => ({
|
||||
getTagLabel: (name: string) => name,
|
||||
@ -159,7 +153,16 @@ vi.mock('@/app/components/tools/mcp', () => ({
|
||||
}))
|
||||
|
||||
const renderProviderList = (searchParams = '') => {
|
||||
return renderWithNuqs(<ProviderList />, { searchParams })
|
||||
const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { enable_marketplace: true },
|
||||
})
|
||||
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsWrapper>
|
||||
<SysWrapper>{children}</SysWrapper>
|
||||
</NuqsWrapper>
|
||||
)
|
||||
return { ...render(<ProviderList />, { wrapper: Wrapper }), onUrlUpdate }
|
||||
}
|
||||
|
||||
describe('Tool Provider List Shell Flow', () => {
|
||||
|
||||
@ -6,10 +6,10 @@ import type { Collection } from '@/app/components/tools/types'
|
||||
* Input (search), and card rendering. Verifies that tab switching, keyword
|
||||
* filtering, and label filtering work together correctly.
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
// ---- Mocks ----
|
||||
@ -36,10 +36,6 @@ vi.mock('nuqs', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({ enable_marketplace: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useTags: () => ({
|
||||
getTagLabel: (key: string) => key,
|
||||
@ -237,12 +233,10 @@ vi.mock('@/app/components/workflow/block-selector/types', () => ({
|
||||
const { default: ProviderList } = await import('@/app/components/tools/provider-list')
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
const { wrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { enable_marketplace: false },
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('Tool Browsing & Filtering Integration', () => {
|
||||
|
||||
127
web/__tests__/utils/mock-system-features.tsx
Normal file
127
web/__tests__/utils/mock-system-features.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import type { RenderHookOptions, RenderHookResult, RenderOptions, RenderResult } from '@testing-library/react'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
|
||||
type DeepPartial<T> = T extends Array<infer U>
|
||||
? Array<U>
|
||||
: T extends object
|
||||
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: T
|
||||
|
||||
const buildSystemFeatures = (
|
||||
overrides: DeepPartial<SystemFeatures> = {},
|
||||
): SystemFeatures => {
|
||||
const o = overrides as Partial<SystemFeatures>
|
||||
return {
|
||||
...defaultSystemFeatures,
|
||||
...o,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
...(o.branding ?? {}),
|
||||
},
|
||||
webapp_auth: {
|
||||
...defaultSystemFeatures.webapp_auth,
|
||||
...(o.webapp_auth ?? {}),
|
||||
sso_config: {
|
||||
...defaultSystemFeatures.webapp_auth.sso_config,
|
||||
...(o.webapp_auth?.sso_config ?? {}),
|
||||
},
|
||||
},
|
||||
plugin_installation_permission: {
|
||||
...defaultSystemFeatures.plugin_installation_permission,
|
||||
...(o.plugin_installation_permission ?? {}),
|
||||
},
|
||||
license: {
|
||||
...defaultSystemFeatures.license,
|
||||
...(o.license ?? {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a QueryClient suitable for tests. Any unseeded query stays in the
|
||||
* "pending" state forever because the default queryFn never resolves; this
|
||||
* mirrors the behaviour of an in-flight network request without touching the
|
||||
* real fetch layer.
|
||||
*/
|
||||
export const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
staleTime: Infinity,
|
||||
queryFn: () => new Promise(() => {}),
|
||||
},
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
export const seedSystemFeatures = (
|
||||
queryClient: QueryClient,
|
||||
overrides: DeepPartial<SystemFeatures> = {},
|
||||
): SystemFeatures => {
|
||||
const data = buildSystemFeatures(overrides)
|
||||
queryClient.setQueryData(consoleQuery.systemFeatures.queryKey(), data)
|
||||
return data
|
||||
}
|
||||
|
||||
type SystemFeaturesTestOptions = {
|
||||
/**
|
||||
* Partial overrides for the systemFeatures payload. When omitted, the cache
|
||||
* is seeded with `defaultSystemFeatures` so consumers using
|
||||
* `useSuspenseQuery` resolve immediately. Pass `null` to skip seeding and
|
||||
* keep the systemFeatures query in the pending state.
|
||||
*/
|
||||
systemFeatures?: DeepPartial<SystemFeatures> | null
|
||||
queryClient?: QueryClient
|
||||
}
|
||||
|
||||
type SystemFeaturesWrapper = {
|
||||
queryClient: QueryClient
|
||||
systemFeatures: SystemFeatures | null
|
||||
wrapper: (props: { children: ReactNode }) => ReactElement
|
||||
}
|
||||
|
||||
export const createSystemFeaturesWrapper = (
|
||||
options: SystemFeaturesTestOptions = {},
|
||||
): SystemFeaturesWrapper => {
|
||||
const queryClient = options.queryClient ?? createTestQueryClient()
|
||||
const systemFeatures = options.systemFeatures === null
|
||||
? null
|
||||
: seedSystemFeatures(queryClient, options.systemFeatures)
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
return { queryClient, systemFeatures, wrapper }
|
||||
}
|
||||
|
||||
export const renderWithSystemFeatures = (
|
||||
ui: ReactElement,
|
||||
options: SystemFeaturesTestOptions & Omit<RenderOptions, 'wrapper'> = {},
|
||||
): RenderResult & { queryClient: QueryClient, systemFeatures: SystemFeatures | null } => {
|
||||
const { systemFeatures: sf, queryClient: qc, ...renderOptions } = options
|
||||
const { wrapper, queryClient, systemFeatures } = createSystemFeaturesWrapper({
|
||||
systemFeatures: sf,
|
||||
queryClient: qc,
|
||||
})
|
||||
const rendered = render(ui, { wrapper, ...renderOptions })
|
||||
return { ...rendered, queryClient, systemFeatures }
|
||||
}
|
||||
|
||||
export const renderHookWithSystemFeatures = <Result, Props = void>(
|
||||
callback: (props: Props) => Result,
|
||||
options: SystemFeaturesTestOptions & Omit<RenderHookOptions<Props>, 'wrapper'> = {},
|
||||
): RenderHookResult<Result, Props> & { queryClient: QueryClient, systemFeatures: SystemFeatures | null } => {
|
||||
const { systemFeatures: sf, queryClient: qc, ...hookOptions } = options
|
||||
const { wrapper, queryClient, systemFeatures } = createSystemFeaturesWrapper({
|
||||
systemFeatures: sf,
|
||||
queryClient: qc,
|
||||
})
|
||||
const rendered = renderHook(callback, { wrapper, ...hookOptions })
|
||||
return { ...rendered, queryClient, systemFeatures }
|
||||
}
|
||||
@ -8,8 +8,6 @@ import {
|
||||
RiDashboard2Line,
|
||||
RiFileList3Fill,
|
||||
RiFileList3Line,
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiTerminalBoxFill,
|
||||
RiTerminalBoxLine,
|
||||
RiTerminalWindowFill,
|
||||
@ -43,6 +41,10 @@ type IAppDetailLayoutProps = {
|
||||
appId: string
|
||||
}
|
||||
|
||||
const EvaluationIcon = ({ className }: { className?: string }) => {
|
||||
return <span aria-hidden className={cn('i-custom-vender-line-others-evaluation', className)} />
|
||||
}
|
||||
|
||||
const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
@ -80,14 +82,6 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
})
|
||||
if (canAccessSnippetsAndEvaluation) {
|
||||
navConfig.push({
|
||||
name: t('appMenus.evaluation', { ns: 'common' }),
|
||||
href: `/app/${appId}/evaluation`,
|
||||
icon: RiFlaskLine,
|
||||
selectedIcon: RiFlaskFill,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
navConfig.push({
|
||||
@ -114,6 +108,16 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
})
|
||||
|
||||
if (isCurrentWorkspaceEditor && canAccessSnippetsAndEvaluation) {
|
||||
navConfig.push({
|
||||
name: t('appMenus.evaluation', { ns: 'common' }),
|
||||
href: `/app/${appId}/evaluation`,
|
||||
icon: EvaluationIcon,
|
||||
selectedIcon: EvaluationIcon,
|
||||
})
|
||||
}
|
||||
|
||||
return navConfig
|
||||
}, [canAccessSnippetsAndEvaluation, t])
|
||||
|
||||
|
||||
@ -7,8 +7,6 @@ import {
|
||||
RiEqualizer2Line,
|
||||
RiFileTextFill,
|
||||
RiFileTextLine,
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiFocus2Fill,
|
||||
RiFocus2Line,
|
||||
} from '@remixicon/react'
|
||||
@ -34,6 +32,10 @@ type IAppDetailLayoutProps = {
|
||||
datasetId: string
|
||||
}
|
||||
|
||||
const EvaluationIcon = ({ className }: { className?: string }) => {
|
||||
return <span aria-hidden className={cn('i-custom-vender-line-others-evaluation', className)} />
|
||||
}
|
||||
|
||||
const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
@ -106,16 +108,16 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
selectedIcon: PipelineFill as RemixiconComponentType,
|
||||
disabled: false,
|
||||
},
|
||||
...baseNavigation,
|
||||
...(isRagPipelineDataset && canAccessSnippetsAndEvaluation
|
||||
? [{
|
||||
name: t('datasetMenus.evaluation', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/evaluation`,
|
||||
icon: RiFlaskLine,
|
||||
selectedIcon: RiFlaskFill,
|
||||
icon: EvaluationIcon,
|
||||
selectedIcon: EvaluationIcon,
|
||||
disabled: isButtonDisabledWithPipeline,
|
||||
}]
|
||||
: []),
|
||||
...baseNavigation,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
33
web/app/(commonLayout)/error.tsx
Normal file
33
web/app/(commonLayout)/error.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RootLoading from '@/app/loading'
|
||||
import { isLegacyBase401 } from '@/service/use-common'
|
||||
|
||||
type Props = {
|
||||
error: Error & { digest?: string }
|
||||
unstable_retry: () => void
|
||||
}
|
||||
|
||||
export default function CommonLayoutError({ error, unstable_retry }: Props) {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
// 401 already triggered jumpTo(/signin) inside service/base.ts. Render Loading
|
||||
// until the browser navigation completes, matching main's Splash behavior.
|
||||
// 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 (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body">
|
||||
<div className="system-sm-regular text-text-tertiary">
|
||||
{t('errorBoundary.message')}
|
||||
</div>
|
||||
<Button size="small" variant="secondary" onClick={() => unstable_retry()}>
|
||||
{t('errorBoundary.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -2,7 +2,6 @@ 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 GA, { GaType } from '@/app/components/base/ga'
|
||||
import Zendesk from '@/app/components/base/zendesk'
|
||||
import { GotoAnything } from '@/app/components/goto-anything'
|
||||
@ -14,14 +13,12 @@ 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 Splash from '../components/splash'
|
||||
import RoleRouteGuard from './role-route-guard'
|
||||
|
||||
const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
@ -37,7 +34,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
<PartnerStack />
|
||||
<ReadmePanel />
|
||||
<GotoAnything />
|
||||
<Splash />
|
||||
</ModalContextProvider>
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
|
||||
9
web/app/(commonLayout)/loading.tsx
Normal file
9
web/app/(commonLayout)/loading.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import Header from '@/app/signin/_header'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
'use client'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { SSOProtocol } from '@/types/feature'
|
||||
|
||||
const ExternalMemberSSOAuth = () => {
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
@ -2,13 +2,14 @@
|
||||
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
|
||||
export default function SignInLayout({ children }: PropsWithChildren) {
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
useDocumentTitle(t('webapp.login', { ns: 'login' }))
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import Link from '@/next/link'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { LicenseStatus } from '@/types/feature'
|
||||
import MailAndCodeAuth from './components/mail-and-code-auth'
|
||||
import MailAndPasswordAuth from './components/mail-and-password-auth'
|
||||
@ -17,7 +18,7 @@ const NormalForm = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const [authType, updateAuthType] = useState<'code' | 'password'>('password')
|
||||
const [showORLine, setShowORLine] = useState(false)
|
||||
const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false)
|
||||
|
||||
@ -1,20 +1,21 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { webAppLogout } from '@/service/webapp-auth'
|
||||
import ExternalMemberSsoAuth from './components/external-member-sso-auth'
|
||||
import NormalForm from './normalForm'
|
||||
|
||||
const WebSSOForm: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
@ -7,7 +7,7 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import {
|
||||
RiGraduationCapFill,
|
||||
} from '@remixicon/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
@ -15,11 +15,11 @@ import Input from '@/app/components/base/input'
|
||||
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 { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useAppList } from '@/service/use-apps'
|
||||
import { commonQueryKeys, useUserProfile } from '@/service/use-common'
|
||||
import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common'
|
||||
import DeleteAccount from '../delete-account'
|
||||
|
||||
import AvatarWithEdit from './AvatarWithEdit'
|
||||
@ -34,12 +34,13 @@ const descriptionClassName = `
|
||||
|
||||
export default function AccountPage() {
|
||||
const { t } = useTranslation()
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: appList } = useAppList({ page: 1, limit: 100, name: '' })
|
||||
const apps = appList?.data || []
|
||||
const queryClient = useQueryClient()
|
||||
const { data: userProfileResp } = useUserProfile()
|
||||
const userProfile = userProfileResp?.profile
|
||||
// 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 { isEducationAccount } = useProviderContext()
|
||||
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
|
||||
|
||||
@ -4,6 +4,7 @@ import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import {
|
||||
RiGraduationCapFill,
|
||||
} from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { Fragment } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
@ -11,13 +12,14 @@ import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useLogout, useUserProfile } from '@/service/use-common'
|
||||
import { useLogout, userProfileQueryOptions } from '@/service/use-common'
|
||||
|
||||
export default function AppSelector() {
|
||||
const router = useRouter()
|
||||
const { t } = useTranslation()
|
||||
const { data: userProfileResp } = useUserProfile()
|
||||
const userProfile = userProfileResp?.profile
|
||||
// Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously.
|
||||
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
|
||||
const userProfile = userProfileResp.profile
|
||||
const { isEducationAccount } = useProviderContext()
|
||||
|
||||
const { mutateAsync: logout } = useLogout()
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
'use client'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { RiArrowRightUpLine, RiRobot2Line } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import Avatar from './avatar'
|
||||
|
||||
const Header = () => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
const goToStudio = useCallback(() => {
|
||||
router.push('/apps')
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { AppInitializer } from '@/app/components/app-initializer'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
@ -14,7 +13,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
|
||||
@ -1,20 +1,27 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
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 { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useIsLogin } from '@/service/use-common'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
useDocumentTitle('')
|
||||
const { isLoading, data: loginData } = useIsLogin()
|
||||
const isLoggedIn = loginData?.logged_in
|
||||
// Probe login state. 401 stays as `error` (not thrown) so this layout can render
|
||||
// the signin/oauth UI for unauthenticated users; other errors bubble to error.tsx.
|
||||
// (When unauthenticated, service/base.ts's auto-redirect to /signin still fires.)
|
||||
const { isPending, data: userResp, error } = useQuery({
|
||||
...userProfileQueryOptions(),
|
||||
throwOnError: err => !isLegacyBase401(err),
|
||||
})
|
||||
const isLoggedIn = !!userResp && !error
|
||||
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full justify-center bg-background-default-burn">
|
||||
<Loading />
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
RiMailLine,
|
||||
RiTranslate2,
|
||||
} from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -17,7 +18,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useIsLogin, useUserProfile } from '@/service/use-common'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
|
||||
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
|
||||
|
||||
function buildReturnUrl(pathname: string, search: string) {
|
||||
@ -61,15 +62,20 @@ export default function OAuthAuthorize() {
|
||||
const searchParams = useSearchParams()
|
||||
const client_id = decodeURIComponent(searchParams.get('client_id') || '')
|
||||
const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '')
|
||||
const { data: userProfileResp } = useUserProfile()
|
||||
// Probe user profile. 401 stays as `error` (legitimate "not logged in" state),
|
||||
// other errors throw to the nearest error.tsx; jumpTo same-pathname guard in
|
||||
// service/base.ts prevents a redirect loop here.
|
||||
const { data: userProfileResp, isPending: isProfileLoading, error: profileError } = useQuery({
|
||||
...userProfileQueryOptions(),
|
||||
throwOnError: err => !isLegacyBase401(err),
|
||||
})
|
||||
const isLoggedIn = !!userProfileResp && !profileError
|
||||
const userProfile = userProfileResp?.profile
|
||||
const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri)
|
||||
const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp()
|
||||
const hasNotifiedRef = useRef(false)
|
||||
|
||||
const { isLoading: isIsLoginLoading, data: loginData } = useIsLogin()
|
||||
const isLoggedIn = loginData?.logged_in
|
||||
const isLoading = isOAuthLoading || isIsLoginLoading
|
||||
const isLoading = isOAuthLoading || isProfileLoading
|
||||
const onLoginSwitchClick = () => {
|
||||
try {
|
||||
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`)
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import Header from '../signin/_header'
|
||||
import ActivateForm from './activateForm'
|
||||
|
||||
const Activate = () => {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
return (
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
import type { MockedFunction } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useUserProfile } from '@/service/use-common'
|
||||
import Splash from '../splash'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useUserProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseUserProfile = useUserProfile as MockedFunction<typeof useUserProfile>
|
||||
|
||||
describe('Splash', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the loading indicator while the profile query is pending', () => {
|
||||
mockUseUserProfile.mockReturnValue({
|
||||
isPending: true,
|
||||
isError: false,
|
||||
data: undefined,
|
||||
} as ReturnType<typeof useUserProfile>)
|
||||
|
||||
render(<Splash />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render the loading indicator when the profile query succeeds', () => {
|
||||
mockUseUserProfile.mockReturnValue({
|
||||
isPending: false,
|
||||
isError: false,
|
||||
data: {
|
||||
profile: { id: 'user-1' },
|
||||
meta: {
|
||||
currentVersion: '1.13.3',
|
||||
currentEnv: 'DEVELOPMENT',
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useUserProfile>)
|
||||
|
||||
render(<Splash />)
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should stop rendering the loading indicator when the profile query errors', () => {
|
||||
mockUseUserProfile.mockReturnValue({
|
||||
isPending: false,
|
||||
isError: true,
|
||||
data: undefined,
|
||||
error: new Error('profile request failed'),
|
||||
} as ReturnType<typeof useUserProfile>)
|
||||
|
||||
render(<Splash />)
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -8,6 +8,7 @@ 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 { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking'
|
||||
import { sendGAEvent } from '@/utils/gtag'
|
||||
@ -101,5 +102,5 @@ export const AppInitializer = ({
|
||||
})()
|
||||
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser])
|
||||
|
||||
return init ? children : null
|
||||
return init ? children : <RootLoading />
|
||||
}
|
||||
|
||||
@ -126,7 +126,7 @@ const AppInfoDetailPanel = ({
|
||||
secondaryOperations={secondaryOperations}
|
||||
/>
|
||||
</div>
|
||||
{appDetail.type !== AppTypeEnum.EVALUATION && (
|
||||
{appDetail.workflow_kind !== AppTypeEnum.EVALUATION && (
|
||||
<CardView
|
||||
appId={appDetail.id}
|
||||
isInPanel={true}
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import AccessControlDialog from '../access-control-dialog'
|
||||
|
||||
@ -1,10 +1,23 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { App } from '@/types/app'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import AccessControl from '../index'
|
||||
|
||||
let mockWebappAuth = {
|
||||
enabled: true,
|
||||
allow_sso: true,
|
||||
allow_email_password_login: false,
|
||||
allow_email_code_login: false,
|
||||
}
|
||||
|
||||
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: { webapp_auth: mockWebappAuth },
|
||||
})
|
||||
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
isPending: false,
|
||||
@ -12,20 +25,6 @@ const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
}))
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
let mockWebappAuth = {
|
||||
enabled: true,
|
||||
allow_sso: true,
|
||||
allow_email_password_login: false,
|
||||
allow_email_code_login: false,
|
||||
}
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: typeof mockWebappAuth } }) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: mockWebappAuth,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
|
||||
@ -5,11 +5,12 @@ import { Description as DialogDescription, DialogTitle } from '@headlessui/react
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import { useUpdateAccessMode } from '@/service/access-control'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import AccessControlDialog from './access-control-dialog'
|
||||
import AccessControlItem from './access-control-item'
|
||||
@ -24,7 +25,7 @@ type AccessControlProps = {
|
||||
export default function AccessControl(props: AccessControlProps) {
|
||||
const { app, onClose, onConfirm } = props
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const setAppId = useAccessControlStore(s => s.setAppId)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum, AppTypeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import AppPublisher from '../index'
|
||||
|
||||
const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: { webapp_auth: { enabled: true } },
|
||||
})
|
||||
|
||||
const mockOnPublish = vi.fn()
|
||||
const mockOnToggle = vi.fn()
|
||||
const mockSetAppDetail = vi.fn()
|
||||
@ -19,6 +24,7 @@ const mockConvertWorkflowType = vi.fn()
|
||||
const mockRefetchEvaluationWorkflowAssociatedTargets = vi.fn()
|
||||
const mockWindowOpen = vi.fn()
|
||||
const mockInvalidateAppWorkflow = vi.fn()
|
||||
let mockCanAccessSnippetsAndEvaluation = true
|
||||
|
||||
const sectionProps = vi.hoisted(() => ({
|
||||
summary: null as null | Record<string, any>,
|
||||
@ -53,16 +59,6 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: () => 'moments ago',
|
||||
@ -73,6 +69,13 @@ vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => mockOpenAsyncWindow,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
|
||||
useSnippetAndEvaluationPlanAccess: () => ({
|
||||
canAccess: mockCanAccessSnippetsAndEvaluation,
|
||||
isReady: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: () => ({
|
||||
data: { result: true },
|
||||
@ -125,11 +128,11 @@ vi.mock('@/app/components/base/amplitude', () => ({
|
||||
vi.mock('@/app/components/app/overview/embedded', () => ({
|
||||
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (isShow
|
||||
? (
|
||||
<div data-testid="embedded-modal">
|
||||
embedded modal
|
||||
<button onClick={onClose}>close-embedded-modal</button>
|
||||
</div>
|
||||
)
|
||||
<div data-testid="embedded-modal">
|
||||
embedded modal
|
||||
<button onClick={onClose}>close-embedded-modal</button>
|
||||
</div>
|
||||
)
|
||||
: null),
|
||||
}))
|
||||
|
||||
@ -201,6 +204,7 @@ describe('AppPublisher', () => {
|
||||
sectionProps.summary = null
|
||||
sectionProps.access = null
|
||||
sectionProps.actions = null
|
||||
mockCanAccessSnippetsAndEvaluation = true
|
||||
mockAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Demo App',
|
||||
@ -567,7 +571,7 @@ describe('AppPublisher', () => {
|
||||
it('should switch workflow type, refresh app detail, and close the popover for published apps', async () => {
|
||||
mockFetchAppDetailDirect.mockResolvedValueOnce({
|
||||
id: 'app-1',
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
workflow_kind: AppTypeEnum.EVALUATION,
|
||||
})
|
||||
|
||||
render(
|
||||
@ -587,166 +591,24 @@ describe('AppPublisher', () => {
|
||||
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
|
||||
expect(mockSetAppDetail).toHaveBeenCalledWith({
|
||||
id: 'app-1',
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
workflow_kind: AppTypeEnum.EVALUATION,
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide access and actions sections for evaluation workflow apps', () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
}
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
|
||||
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
|
||||
expect(screen.queryByText('publisher-access-control')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('publisher-embed')).not.toBeInTheDocument()
|
||||
expect(sectionProps.summary?.workflowTypeSwitchConfig).toEqual({
|
||||
targetType: AppTypeEnum.WORKFLOW,
|
||||
publishLabelKey: 'common.publishAsStandardWorkflow',
|
||||
switchLabelKey: 'common.switchToStandardWorkflow',
|
||||
tipKey: 'common.switchToStandardWorkflowTip',
|
||||
})
|
||||
})
|
||||
|
||||
it('should confirm before switching an evaluation workflow with associated targets to a standard workflow', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
}
|
||||
mockEvaluationWorkflowAssociatedTargets = {
|
||||
items: [
|
||||
{
|
||||
target_type: 'app',
|
||||
target_id: 'dependent-app-1',
|
||||
target_name: 'Dependent App',
|
||||
},
|
||||
{
|
||||
target_type: 'knowledge_base',
|
||||
target_id: 'knowledge-1',
|
||||
target_name: 'Knowledge Base',
|
||||
},
|
||||
],
|
||||
}
|
||||
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValueOnce({
|
||||
data: mockEvaluationWorkflowAssociatedTargets,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefetchEvaluationWorkflowAssociatedTargets).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Dependent App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.switchToStandardWorkflowConfirm.switch' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
|
||||
params: { appId: 'app-1' },
|
||||
query: { target_type: AppTypeEnum.WORKFLOW },
|
||||
})
|
||||
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch an evaluation workflow directly when there are no associated targets', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
}
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefetchEvaluationWorkflowAssociatedTargets).toHaveBeenCalledTimes(1)
|
||||
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
|
||||
params: { appId: 'app-1' },
|
||||
query: { target_type: AppTypeEnum.WORKFLOW },
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText('common.switchToStandardWorkflowConfirm.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should block switching an evaluation workflow when associated targets fail to load', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
}
|
||||
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
isError: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('common.switchToStandardWorkflowConfirm.loadFailed')
|
||||
})
|
||||
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should block switching to evaluation workflow when restricted nodes exist', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
hasHumanInputNode
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('common.switchToEvaluationWorkflowDisabledTip')
|
||||
})
|
||||
|
||||
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
|
||||
expect(sectionProps.summary?.workflowTypeSwitchDisabled).toBe(true)
|
||||
expect(sectionProps.summary?.workflowTypeSwitchDisabledReason).toBe('common.switchToEvaluationWorkflowDisabledTip')
|
||||
})
|
||||
|
||||
it('should switch workflow type, refresh app detail, and close the popover for published apps', async () => {
|
||||
it('should publish an unpublished workflow as evaluation workflow through the evaluation publish endpoint', async () => {
|
||||
mockOnPublish.mockResolvedValue(undefined)
|
||||
mockFetchAppDetailDirect.mockResolvedValueOnce({
|
||||
id: 'app-1',
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
workflow_kind: AppTypeEnum.EVALUATION,
|
||||
})
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -754,23 +616,24 @@ describe('AppPublisher', () => {
|
||||
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
|
||||
params: { appId: 'app-1' },
|
||||
query: { target_type: AppTypeEnum.EVALUATION },
|
||||
expect(mockOnPublish).toHaveBeenCalledWith({
|
||||
url: '/apps/app-1/workflows/publish/evaluation',
|
||||
title: '',
|
||||
releaseNotes: '',
|
||||
})
|
||||
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
|
||||
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
|
||||
expect(mockSetAppDetail).toHaveBeenCalledWith({
|
||||
id: 'app-1',
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
workflow_kind: AppTypeEnum.EVALUATION,
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide access and actions sections for evaluation workflow apps', () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
workflow_kind: AppTypeEnum.EVALUATION,
|
||||
}
|
||||
|
||||
render(
|
||||
@ -795,7 +658,7 @@ describe('AppPublisher', () => {
|
||||
it('should confirm before switching an evaluation workflow with associated targets to a standard workflow', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
workflow_kind: AppTypeEnum.EVALUATION,
|
||||
}
|
||||
mockEvaluationWorkflowAssociatedTargets = {
|
||||
items: [
|
||||
@ -845,7 +708,7 @@ describe('AppPublisher', () => {
|
||||
it('should switch an evaluation workflow directly when there are no associated targets', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
workflow_kind: AppTypeEnum.EVALUATION,
|
||||
}
|
||||
|
||||
render(
|
||||
@ -870,7 +733,7 @@ describe('AppPublisher', () => {
|
||||
it('should block switching an evaluation workflow when associated targets fail to load', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
workflow_kind: AppTypeEnum.EVALUATION,
|
||||
}
|
||||
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
@ -911,4 +774,25 @@ describe('AppPublisher', () => {
|
||||
expect(sectionProps.summary?.workflowTypeSwitchDisabled).toBe(true)
|
||||
expect(sectionProps.summary?.workflowTypeSwitchDisabledReason).toBe('common.switchToEvaluationWorkflowDisabledTip')
|
||||
})
|
||||
|
||||
it('should keep the evaluation workflow switch visible but disabled when the current plan cannot access it', () => {
|
||||
mockCanAccessSnippetsAndEvaluation = false
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
|
||||
expect(sectionProps.summary?.workflowTypeSwitchConfig).toEqual({
|
||||
targetType: AppTypeEnum.EVALUATION,
|
||||
publishLabelKey: 'common.publishAsEvaluationWorkflow',
|
||||
switchLabelKey: 'common.switchToEvaluationWorkflow',
|
||||
tipKey: 'common.switchToEvaluationWorkflowTip',
|
||||
})
|
||||
expect(sectionProps.summary?.workflowTypeSwitchDisabled).toBe(true)
|
||||
expect(sectionProps.summary?.workflowTypeSwitchDisabledReason).toBe('compliance.sandboxUpgradeTooltip')
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
|
||||
import type { EvaluationWorkflowAssociatedTarget, EvaluationWorkflowAssociatedTargetType } from '@/types/evaluation'
|
||||
import type { I18nKeysWithPrefix } from '@/types/i18n'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@ -13,6 +11,8 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from '@/next/link'
|
||||
|
||||
type EvaluationWorkflowSwitchConfirmDialogProps = {
|
||||
@ -80,7 +80,7 @@ const DependentTargetItem = ({
|
||||
<span className={cn(meta.icon, 'size-5')} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 py-px">
|
||||
<span className="system-md-semibold truncate text-text-secondary">
|
||||
<span className="truncate system-md-semibold text-text-secondary">
|
||||
{targetName}
|
||||
</span>
|
||||
<span className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
@ -108,10 +108,10 @@ const EvaluationWorkflowSwitchConfirmDialog = ({
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="w-[480px]">
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold w-full text-text-primary">
|
||||
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
|
||||
{t('common.switchToStandardWorkflowConfirm.title', { ns: 'workflow' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-md-regular w-full text-text-secondary">
|
||||
<AlertDialogDescription className="w-full system-md-regular text-text-secondary">
|
||||
<span className="block">
|
||||
{t('common.switchToStandardWorkflowConfirm.activeIn', { ns: 'workflow', count: targets.length })}
|
||||
</span>
|
||||
@ -123,7 +123,7 @@ const EvaluationWorkflowSwitchConfirmDialog = ({
|
||||
|
||||
<div className="flex flex-col gap-2 px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="system-xs-medium-uppercase shrink-0 text-text-quaternary">
|
||||
<span className="shrink-0 system-xs-medium-uppercase text-text-quaternary">
|
||||
{t('common.switchToStandardWorkflowConfirm.dependentWorkflows', { ns: 'workflow' })}
|
||||
</span>
|
||||
<span className="h-px min-w-0 flex-1 bg-divider-subtle" />
|
||||
|
||||
@ -9,6 +9,7 @@ import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/type
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
|
||||
@ -33,7 +34,6 @@ import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
|
||||
@ -41,6 +41,7 @@ import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useConvertWorkflowTypeMutation } from '@/service/use-apps'
|
||||
import { useEvaluationWorkflowAssociatedTargets } from '@/service/use-evaluation'
|
||||
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
|
||||
@ -150,8 +151,8 @@ const AppPublisher = ({
|
||||
const workflowStore = useContext(WorkflowContext)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(s => s.setAppDetail)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
|
||||
const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation()
|
||||
@ -159,15 +160,15 @@ const AppPublisher = ({
|
||||
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
|
||||
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
|
||||
const workflowTypeSwitchConfig = useMemo(() => {
|
||||
if (!isWorkflowTypeConversionTarget(appDetail?.type))
|
||||
if (!appDetail?.workflow_kind)
|
||||
return WORKFLOW_TYPE_SWITCH_CONFIG.workflow
|
||||
|
||||
if (!isWorkflowTypeConversionTarget(appDetail?.workflow_kind))
|
||||
return undefined
|
||||
|
||||
if (appDetail.type !== AppTypeEnum.EVALUATION && !canAccessSnippetsAndEvaluation)
|
||||
return undefined
|
||||
|
||||
return WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.type]
|
||||
}, [appDetail?.type, canAccessSnippetsAndEvaluation])
|
||||
const isEvaluationWorkflowType = appDetail?.type === AppTypeEnum.EVALUATION
|
||||
return WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.workflow_kind]
|
||||
}, [appDetail?.workflow_kind])
|
||||
const isEvaluationWorkflowType = appDetail?.workflow_kind === AppTypeEnum.EVALUATION
|
||||
const {
|
||||
refetch: refetchEvaluationWorkflowAssociatedTargets,
|
||||
isFetching: isFetchingEvaluationWorkflowAssociatedTargets,
|
||||
@ -176,11 +177,14 @@ const AppPublisher = ({
|
||||
if (workflowTypeSwitchConfig?.targetType !== AppTypeEnum.EVALUATION)
|
||||
return undefined
|
||||
|
||||
if (!canAccessSnippetsAndEvaluation)
|
||||
return t('compliance.sandboxUpgradeTooltip', { ns: 'common' })
|
||||
|
||||
if (!hasHumanInputNode && !hasTriggerNode)
|
||||
return undefined
|
||||
|
||||
return t('common.switchToEvaluationWorkflowDisabledTip', { ns: 'workflow' })
|
||||
}, [hasHumanInputNode, hasTriggerNode, t, workflowTypeSwitchConfig?.targetType])
|
||||
}, [canAccessSnippetsAndEvaluation, hasHumanInputNode, hasTriggerNode, t, workflowTypeSwitchConfig?.targetType])
|
||||
const hiddenLaunchVariables = useMemo<WorkflowHiddenStartVariable[]>(
|
||||
() => (inputs ?? []).filter(input => input.hide === true),
|
||||
[inputs],
|
||||
@ -285,7 +289,7 @@ const AppPublisher = ({
|
||||
throw new Error('App not found')
|
||||
const { installed_apps } = await fetchInstalledAppList(appDetail.id)
|
||||
if (installed_apps?.length > 0)
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
return `${basePath}/explore/installed/${installed_apps[0]!.id}`
|
||||
throw new Error('No app found in Explore')
|
||||
}, {
|
||||
onError: (err) => {
|
||||
@ -306,11 +310,42 @@ const AppPublisher = ({
|
||||
}
|
||||
}, [appDetail, setAppDetail])
|
||||
|
||||
const getWorkflowTypeSwitchPublishUrl = useCallback(() => {
|
||||
if (!appDetail?.id || !workflowTypeSwitchConfig)
|
||||
return undefined
|
||||
|
||||
if (workflowTypeSwitchConfig.targetType === AppTypeEnum.EVALUATION)
|
||||
return `/apps/${appDetail.id}/workflows/publish/evaluation`
|
||||
|
||||
return `/apps/${appDetail.id}/workflows/publish`
|
||||
}, [appDetail?.id, workflowTypeSwitchConfig])
|
||||
|
||||
const performWorkflowTypeSwitch = useCallback(async () => {
|
||||
if (!appDetail?.id || !workflowTypeSwitchConfig)
|
||||
return false
|
||||
|
||||
try {
|
||||
if (!publishedAt) {
|
||||
const publishUrl = getWorkflowTypeSwitchPublishUrl()
|
||||
if (!publishUrl)
|
||||
return false
|
||||
|
||||
await handlePublish({
|
||||
url: publishUrl,
|
||||
title: '',
|
||||
releaseNotes: '',
|
||||
})
|
||||
|
||||
const latestAppDetail = await fetchAppDetailDirect({
|
||||
url: '/apps',
|
||||
id: appDetail.id,
|
||||
})
|
||||
setAppDetail(latestAppDetail)
|
||||
setShowEvaluationWorkflowSwitchConfirm(false)
|
||||
setEvaluationWorkflowSwitchTargets([])
|
||||
return true
|
||||
}
|
||||
|
||||
await convertWorkflowType({
|
||||
params: {
|
||||
appId: appDetail.id,
|
||||
@ -320,9 +355,6 @@ const AppPublisher = ({
|
||||
},
|
||||
})
|
||||
|
||||
if (!publishedAt)
|
||||
await handlePublish()
|
||||
|
||||
const latestAppDetail = await fetchAppDetailDirect({
|
||||
url: '/apps',
|
||||
id: appDetail.id,
|
||||
@ -339,7 +371,7 @@ const AppPublisher = ({
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}, [appDetail?.id, convertWorkflowType, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig])
|
||||
}, [appDetail?.id, convertWorkflowType, getWorkflowTypeSwitchPublishUrl, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig])
|
||||
|
||||
const handleWorkflowTypeSwitch = useCallback(async () => {
|
||||
if (!appDetail?.id || !workflowTypeSwitchConfig)
|
||||
@ -349,7 +381,7 @@ const AppPublisher = ({
|
||||
return
|
||||
}
|
||||
|
||||
if (appDetail.type === AppTypeEnum.EVALUATION && workflowTypeSwitchConfig.targetType === AppTypeEnum.WORKFLOW) {
|
||||
if (appDetail.workflow_kind === AppTypeEnum.EVALUATION && workflowTypeSwitchConfig.targetType === AppTypeEnum.WORKFLOW) {
|
||||
const associatedTargetsResult = await refetchEvaluationWorkflowAssociatedTargets()
|
||||
|
||||
if (associatedTargetsResult.isError) {
|
||||
@ -368,7 +400,7 @@ const AppPublisher = ({
|
||||
await performWorkflowTypeSwitch()
|
||||
}, [
|
||||
appDetail?.id,
|
||||
appDetail?.type,
|
||||
appDetail?.workflow_kind,
|
||||
performWorkflowTypeSwitch,
|
||||
refetchEvaluationWorkflowAssociatedTargets,
|
||||
t,
|
||||
@ -494,80 +526,80 @@ const AppPublisher = ({
|
||||
workflowTypeSwitchDisabledReason={workflowTypeSwitchDisabledReason}
|
||||
onWorkflowTypeSwitch={handleWorkflowTypeSwitch}
|
||||
/>
|
||||
{
|
||||
!isEvaluationWorkflowType && (
|
||||
<>
|
||||
<PublisherAccessSection
|
||||
enabled={systemFeatures.webapp_auth.enabled}
|
||||
isAppAccessSet={isAppAccessSet}
|
||||
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
|
||||
accessMode={appDetail?.access_mode}
|
||||
onClick={() => {
|
||||
handleOpenChange(false)
|
||||
setShowAppAccessControl(true)
|
||||
}}
|
||||
{
|
||||
!isEvaluationWorkflowType && (
|
||||
<>
|
||||
<PublisherAccessSection
|
||||
enabled={systemFeatures.webapp_auth.enabled}
|
||||
isAppAccessSet={isAppAccessSet}
|
||||
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
|
||||
accessMode={appDetail?.access_mode}
|
||||
onClick={() => {
|
||||
handleOpenChange(false)
|
||||
setShowAppAccessControl(true)
|
||||
}}
|
||||
/>
|
||||
<PublisherActionsSection
|
||||
appDetail={appDetail}
|
||||
appURL={appURL}
|
||||
disabledFunctionButton={disabledFunctionButton}
|
||||
disabledFunctionTooltip={disabledFunctionTooltip}
|
||||
handleEmbed={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleOpenChange(false)
|
||||
}}
|
||||
handleOpenInExplore={() => {
|
||||
handleOpenChange(false)
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
handleOpenRunConfig={handleOpenWorkflowLaunchDialog}
|
||||
handlePublish={handlePublish}
|
||||
hasHumanInputNode={hasHumanInputNode}
|
||||
hasTriggerNode={hasTriggerNode}
|
||||
inputs={inputs}
|
||||
missingStartNode={missingStartNode}
|
||||
onRefreshData={onRefreshData}
|
||||
outputs={outputs}
|
||||
published={published}
|
||||
publishedAt={publishedAt}
|
||||
showBatchRunConfig={hiddenLaunchVariables.length > 0 && (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION)}
|
||||
showRunConfig={hiddenLaunchVariables.length > 0}
|
||||
toolPublished={toolPublished}
|
||||
workflowToolAvailable={workflowToolAvailable}
|
||||
workflowToolMessage={workflowToolMessage}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
<EmbeddedModal
|
||||
siteInfo={appDetail?.site}
|
||||
isShow={embeddingModalOpen}
|
||||
onClose={() => setEmbeddingModalOpen(false)}
|
||||
appBaseUrl={appBaseURL}
|
||||
accessToken={accessToken}
|
||||
hiddenInputs={hiddenLaunchVariables}
|
||||
/>
|
||||
<PublisherActionsSection
|
||||
appDetail={appDetail}
|
||||
appURL={appURL}
|
||||
disabledFunctionButton={disabledFunctionButton}
|
||||
disabledFunctionTooltip={disabledFunctionTooltip}
|
||||
handleEmbed={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleOpenChange(false)
|
||||
}}
|
||||
handleOpenInExplore={() => {
|
||||
handleOpenChange(false)
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
handleOpenRunConfig={handleOpenWorkflowLaunchDialog}
|
||||
handlePublish={handlePublish}
|
||||
hasHumanInputNode={hasHumanInputNode}
|
||||
hasTriggerNode={hasTriggerNode}
|
||||
inputs={inputs}
|
||||
missingStartNode={missingStartNode}
|
||||
onRefreshData={onRefreshData}
|
||||
outputs={outputs}
|
||||
published={published}
|
||||
publishedAt={publishedAt}
|
||||
showBatchRunConfig={hiddenLaunchVariables.length > 0 && (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION)}
|
||||
showRunConfig={hiddenLaunchVariables.length > 0}
|
||||
toolPublished={toolPublished}
|
||||
workflowToolAvailable={workflowToolAvailable}
|
||||
workflowToolMessage={workflowToolMessage}
|
||||
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} /> }
|
||||
<WorkflowLaunchDialog
|
||||
t={t}
|
||||
open={workflowLaunchDialogOpen}
|
||||
hiddenVariables={supportedWorkflowLaunchVariables}
|
||||
unsupportedVariables={unsupportedWorkflowLaunchVariables}
|
||||
values={workflowLaunchValues}
|
||||
onOpenChange={setWorkflowLaunchDialogOpen}
|
||||
onValueChange={handleWorkflowLaunchValueChange}
|
||||
onSubmit={handleWorkflowLaunchConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
</PopoverContent >
|
||||
<EmbeddedModal
|
||||
siteInfo={appDetail?.site}
|
||||
isShow={embeddingModalOpen}
|
||||
onClose={() => setEmbeddingModalOpen(false)}
|
||||
appBaseUrl={appBaseURL}
|
||||
accessToken={accessToken}
|
||||
hiddenInputs={hiddenLaunchVariables}
|
||||
/>
|
||||
{ showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} /> }
|
||||
<WorkflowLaunchDialog
|
||||
t={t}
|
||||
open={workflowLaunchDialogOpen}
|
||||
hiddenVariables={supportedWorkflowLaunchVariables}
|
||||
unsupportedVariables={unsupportedWorkflowLaunchVariables}
|
||||
values={workflowLaunchValues}
|
||||
onOpenChange={setWorkflowLaunchDialogOpen}
|
||||
onValueChange={handleWorkflowLaunchValueChange}
|
||||
onSubmit={handleWorkflowLaunchConfirm}
|
||||
/>
|
||||
</Popover >
|
||||
<EvaluationWorkflowSwitchConfirmDialog
|
||||
open={showEvaluationWorkflowSwitchConfirm}
|
||||
targets={evaluationWorkflowSwitchTargets}
|
||||
loading={isConvertingWorkflowType}
|
||||
onOpenChange={handleEvaluationWorkflowSwitchConfirmOpenChange}
|
||||
onConfirm={() => void performWorkflowTypeSwitch()}
|
||||
/>
|
||||
</Popover>
|
||||
<EvaluationWorkflowSwitchConfirmDialog
|
||||
open={showEvaluationWorkflowSwitchConfirm}
|
||||
targets={evaluationWorkflowSwitchTargets}
|
||||
loading={isConvertingWorkflowType}
|
||||
onOpenChange={handleEvaluationWorkflowSwitchConfirmOpenChange}
|
||||
onConfirm={() => void performWorkflowTypeSwitch()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,12 +4,12 @@ import type { AppPublisherProps } from './index'
|
||||
import type { I18nKeysWithPrefix } from '@/types/i18n'
|
||||
import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { RiSettings2Line } from '@remixicon/react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import { RiSettings2Line } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
|
||||
@ -156,117 +156,117 @@ export const PublisherSummarySection = ({
|
||||
</div>
|
||||
{publishedAt
|
||||
? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center system-sm-medium text-text-secondary">
|
||||
{t('common.publishedAt', { ns: 'workflow' })}
|
||||
{' '}
|
||||
{formatTimeFromNow(publishedAt)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center system-sm-medium text-text-secondary">
|
||||
{t('common.publishedAt', { ns: 'workflow' })}
|
||||
{' '}
|
||||
{formatTimeFromNow(publishedAt)}
|
||||
</div>
|
||||
{isChatApp && (
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
onClick={handleRestore}
|
||||
disabled={published}
|
||||
>
|
||||
{t('common.restore', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isChatApp && (
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
onClick={handleRestore}
|
||||
disabled={published}
|
||||
>
|
||||
{t('common.restore', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center system-sm-medium text-text-secondary">
|
||||
{t('common.autoSaved', { ns: 'workflow' })}
|
||||
{' '}
|
||||
·
|
||||
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center system-sm-medium text-text-secondary">
|
||||
{t('common.autoSaved', { ns: 'workflow' })}
|
||||
{' '}
|
||||
·
|
||||
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
|
||||
</div>
|
||||
)}
|
||||
{debugWithMultipleModel
|
||||
? (
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onSelect={item => handlePublish(item)}
|
||||
/>
|
||||
)
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onSelect={item => handlePublish(item)}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-3 w-full"
|
||||
onClick={() => handlePublish()}
|
||||
disabled={publishDisabled || published}
|
||||
>
|
||||
{published
|
||||
? t('common.published', { ns: 'workflow' })
|
||||
: (
|
||||
<div className="flex gap-1">
|
||||
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={publishShortcut} bgColor="white" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
{workflowTypeSwitchConfig && (
|
||||
<ActionTooltip disabled={workflowTypeSwitchDisabled} tooltip={workflowTypeSwitchDisabledReason}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full items-center justify-center gap-0.5 rounded-lg px-3 py-2 system-sm-medium text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => void onWorkflowTypeSwitch()}
|
||||
disabled={workflowTypeSwitchDisabled}
|
||||
>
|
||||
<span className="px-0.5">
|
||||
{t(
|
||||
publishedAt
|
||||
? workflowTypeSwitchConfig.switchLabelKey
|
||||
: workflowTypeSwitchConfig.publishLabelKey,
|
||||
{ ns: 'workflow' },
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-3 w-full"
|
||||
onClick={() => handlePublish()}
|
||||
disabled={publishDisabled || published}
|
||||
>
|
||||
{published
|
||||
? t('common.published', { ns: 'workflow' })
|
||||
: (
|
||||
<div className="flex gap-1">
|
||||
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={publishShortcut} bgColor="white" />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span
|
||||
className="flex h-4 w-4 items-center justify-center text-text-quaternary hover:text-text-tertiary"
|
||||
aria-label={t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-question-line h-3.5 w-3.5" />
|
||||
</span>
|
||||
</Button>
|
||||
{workflowTypeSwitchConfig && (
|
||||
<ActionTooltip disabled={workflowTypeSwitchDisabled} tooltip={workflowTypeSwitchDisabledReason}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full items-center justify-center gap-0.5 rounded-lg px-3 py-2 system-sm-medium text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => void onWorkflowTypeSwitch()}
|
||||
disabled={workflowTypeSwitchDisabled}
|
||||
>
|
||||
<span className="px-0.5">
|
||||
{t(
|
||||
publishedAt
|
||||
? workflowTypeSwitchConfig.switchLabelKey
|
||||
: workflowTypeSwitchConfig.publishLabelKey,
|
||||
{ ns: 'workflow' },
|
||||
)}
|
||||
/>
|
||||
<TooltipContent
|
||||
placement="top"
|
||||
className="w-[180px]"
|
||||
>
|
||||
{t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</button>
|
||||
</ActionTooltip>
|
||||
)}
|
||||
{startNodeLimitExceeded && (
|
||||
<div className="mt-3 flex flex-col items-stretch">
|
||||
<p
|
||||
className="text-sm leading-5 font-semibold text-transparent"
|
||||
style={upgradeHighlightStyle}
|
||||
>
|
||||
<span className="block">{t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })}</span>
|
||||
<span className="block">{t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-text-secondary">
|
||||
{t('publishLimit.startNodeDesc', { ns: 'workflow' })}
|
||||
</p>
|
||||
<UpgradeBtn
|
||||
isShort
|
||||
className="mt-[9px] mb-[12px] h-[32px] w-[93px] self-start"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span
|
||||
className="flex h-4 w-4 items-center justify-center text-text-quaternary hover:text-text-tertiary"
|
||||
aria-label={t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-question-line h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent
|
||||
placement="top"
|
||||
className="w-[180px]"
|
||||
>
|
||||
{t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</button>
|
||||
</ActionTooltip>
|
||||
)}
|
||||
{startNodeLimitExceeded && (
|
||||
<div className="mt-3 flex flex-col items-stretch">
|
||||
<p
|
||||
className="text-sm leading-5 font-semibold text-transparent"
|
||||
style={upgradeHighlightStyle}
|
||||
>
|
||||
<span className="block">{t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })}</span>
|
||||
<span className="block">{t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-text-secondary">
|
||||
{t('publishLimit.startNodeDesc', { ns: 'workflow' })}
|
||||
</p>
|
||||
<UpgradeBtn
|
||||
isShort
|
||||
className="mt-[9px] mb-[12px] h-[32px] w-[93px] self-start"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -350,10 +350,10 @@ export const PublisherActionsSection = ({
|
||||
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
|
||||
actionButton={showRunConfig
|
||||
? {
|
||||
ariaLabel: t('operation.config', { ns: 'common' }),
|
||||
icon: <RiSettings2Line className="h-4 w-4" />,
|
||||
onClick: () => handleOpenRunConfig?.(appURL),
|
||||
}
|
||||
ariaLabel: t('operation.config', { ns: 'common' }),
|
||||
icon: <RiSettings2Line className="h-4 w-4" />,
|
||||
onClick: () => handleOpenRunConfig?.(appURL),
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{t('common.runApp', { ns: 'workflow' })}
|
||||
@ -361,34 +361,34 @@ export const PublisherActionsSection = ({
|
||||
</ActionTooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
|
||||
? (
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
onClick={handleEmbed}
|
||||
disabled={disabledFunctionButton || !publishedAt}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
|
||||
actionButton={showBatchRunConfig
|
||||
? {
|
||||
ariaLabel: t('operation.config', { ns: 'common' }),
|
||||
icon: <RiSettings2Line className="h-4 w-4" />,
|
||||
onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`),
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{t('common.batchRunApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
)
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
onClick={handleEmbed}
|
||||
disabled={disabledFunctionButton || !publishedAt}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
|
||||
actionButton={showBatchRunConfig
|
||||
? {
|
||||
ariaLabel: t('operation.config', { ns: 'common' }),
|
||||
icon: <RiSettings2Line className="h-4 w-4" />,
|
||||
onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`),
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{t('common.batchRunApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
)
|
||||
: (
|
||||
<SuggestedAction
|
||||
onClick={handleEmbed}
|
||||
disabled={!publishedAt}
|
||||
icon={<CodeBrowser className="h-4 w-4" />}
|
||||
>
|
||||
{t('common.embedIntoSite', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<SuggestedAction
|
||||
onClick={handleEmbed}
|
||||
disabled={!publishedAt}
|
||||
icon={<CodeBrowser className="h-4 w-4" />}
|
||||
>
|
||||
{t('common.embedIntoSite', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { App } from '@/models/explore'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import AppListContext from '@/context/app-list-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
@ -4,13 +4,14 @@ import { PlusIcon } from '@heroicons/react/20/solid'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiInformation2Line } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppListContext from '@/context/app-list-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
|
||||
|
||||
type AppCardProps = {
|
||||
@ -26,7 +27,7 @@ const AppCard = ({
|
||||
}: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { app: appBasicInfo } = app
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
|
||||
const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel)
|
||||
const handleShowTryAppPanel = useCallback(() => {
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import AppCard from '../app-card'
|
||||
|
||||
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: { webapp_auth: { enabled: true } },
|
||||
})
|
||||
|
||||
const mockFetchAppDetailDirect = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
const mockSetAppDetail = vi.fn()
|
||||
@ -37,16 +42,6 @@ vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { appDetail: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
|
||||
appDetail: mockAppDetail as AppDetailResponse,
|
||||
|
||||
@ -4,6 +4,7 @@ import type { ConfigParams } from './settings'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -13,12 +14,12 @@ import Tooltip from '@/app/components/base/tooltip'
|
||||
import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { useAppWhiteListSubjects } from '@/service/access-control'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
@ -82,7 +83,7 @@ function AppCard({
|
||||
const [showWorkflowLaunchDialog, setShowWorkflowLaunchDialog] = useState(false)
|
||||
const [workflowLaunchValues, setWorkflowLaunchValues] = useState<Record<string, WorkflowLaunchInputValue>>({})
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: appAccessSubjects } = useAppWhiteListSubjects(
|
||||
appDetail?.id,
|
||||
systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import * as appsService from '@/service/apps'
|
||||
import * as exploreService from '@/service/explore'
|
||||
@ -9,6 +10,15 @@ import * as workflowService from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from '../app-card'
|
||||
|
||||
let mockWebappAuthEnabled = false
|
||||
|
||||
const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: {
|
||||
webapp_auth: { enabled: mockWebappAuthEnabled },
|
||||
branding: { enabled: false },
|
||||
},
|
||||
})
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@ -65,16 +75,7 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock global public store - allow dynamic configuration
|
||||
let mockWebappAuthEnabled = false
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (s: Record<string, unknown>) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: { enabled: mockWebappAuthEnabled },
|
||||
branding: { enabled: false },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
// systemFeatures is seeded into the QueryClient via the local render helper.
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
deleteApp: vi.fn(() => Promise.resolve()),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@ -293,14 +294,19 @@ beforeAll(() => {
|
||||
} as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
// Render helper wrapping with shared nuqs testing helper plus a seeded
|
||||
// systemFeatures cache so List can resolve its useSuspenseQuery.
|
||||
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
|
||||
return renderWithNuqs(<List {...props} />, { searchParams })
|
||||
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { branding: { enabled: false } },
|
||||
})
|
||||
return renderWithNuqs(<SystemFeaturesWrapper><List {...props} /></SystemFeaturesWrapper>, { searchParams })
|
||||
}
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
defaultSnippetData.pages[0].data = [
|
||||
defaultSnippetData.pages[0]!.data = [
|
||||
{
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
@ -319,7 +325,7 @@ describe('List', () => {
|
||||
author: '',
|
||||
},
|
||||
]
|
||||
defaultSnippetData.pages[0].total = 1
|
||||
defaultSnippetData.pages[0]!.total = 1
|
||||
useTagStore.setState({
|
||||
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
|
||||
showTagManagementModal: false,
|
||||
@ -371,7 +377,7 @@ describe('List', () => {
|
||||
fireEvent.click(await screen.findByText('app.types.workflow'))
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
@ -465,7 +471,7 @@ describe('List', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { unmount } = renderWithNuqs(<List />)
|
||||
const { unmount } = renderList()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
@ -35,7 +36,6 @@ import Tooltip from '@/app/components/base/tooltip'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
@ -44,6 +44,7 @@ import { useRouter } from '@/next/navigation'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { copyApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useDeleteAppMutation } from '@/service/use-apps'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@ -182,7 +183,7 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
|
||||
type AppCardOperationsMenuContentProps = Omit<AppCardOperationsMenuProps, 'shouldShowOpenInExploreOption'>
|
||||
|
||||
const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps> = (props) => {
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({
|
||||
appId: props.app.id,
|
||||
enabled: systemFeatures.webapp_auth.enabled,
|
||||
@ -205,7 +206,7 @@ const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps>
|
||||
const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const deleteAppNameInputId = useId()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { onPlanInfoChanged } = useProviderContext()
|
||||
const { push } = useRouter()
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
|
||||
import type { AppListCategory } from './app-type-filter-shared'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -12,6 +10,8 @@ import {
|
||||
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'
|
||||
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import type { FC } from 'react'
|
||||
import type { StudioPageType } from '.'
|
||||
import type { WorkflowOnlineUser } from '@/models/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@ -13,11 +14,11 @@ import TagFilter from '@/app/components/base/tag-management/filter'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { fetchWorkflowOnlineUsers } from '@/service/apps'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { useInfiniteSnippetList } from '@/service/use-snippets'
|
||||
import SnippetCard from '../snippets/components/snippet-card'
|
||||
@ -53,7 +54,7 @@ const List: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
const isAppsPage = pageType === 'apps'
|
||||
const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
|
||||
@ -1,82 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import type { AmplitudeInitializationOptions } from './init'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { AMPLITUDE_API_KEY } from '@/config'
|
||||
import { ensureAmplitudeInitialized } from './init'
|
||||
|
||||
export type IAmplitudeProps = {
|
||||
sessionReplaySampleRate?: number
|
||||
}
|
||||
|
||||
// Map URL pathname to English page name for consistent Amplitude tracking
|
||||
const getEnglishPageName = (pathname: string): string => {
|
||||
// Remove leading slash and get the first segment
|
||||
const segments = pathname.replace(/^\//, '').split('/')
|
||||
const firstSegment = segments[0] || 'home'
|
||||
|
||||
const pageNameMap: Record<string, string> = {
|
||||
'': 'Home',
|
||||
'apps': 'Studio',
|
||||
'datasets': 'Knowledge',
|
||||
'explore': 'Explore',
|
||||
'tools': 'Tools',
|
||||
'account': 'Account',
|
||||
'signin': 'Sign In',
|
||||
'signup': 'Sign Up',
|
||||
}
|
||||
|
||||
return pageNameMap[firstSegment] || firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1)
|
||||
}
|
||||
|
||||
// Enrichment plugin to override page title with English name for page view events
|
||||
const pageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
|
||||
return {
|
||||
name: 'page-name-enrichment',
|
||||
type: 'enrichment',
|
||||
setup: async () => undefined,
|
||||
execute: async (event: amplitude.Types.Event) => {
|
||||
// Only modify page view events
|
||||
if (event.event_type === '[Amplitude] Page Viewed' && event.event_properties) {
|
||||
/* v8 ignore next @preserve */
|
||||
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
|
||||
event.event_properties['[Amplitude] Page Title'] = getEnglishPageName(pathname)
|
||||
}
|
||||
return event
|
||||
},
|
||||
}
|
||||
}
|
||||
export type IAmplitudeProps = AmplitudeInitializationOptions
|
||||
|
||||
const AmplitudeProvider: FC<IAmplitudeProps> = ({
|
||||
sessionReplaySampleRate = 0.5,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
// Only enable in Saas edition with valid API key
|
||||
// if (!isAmplitudeEnabled)
|
||||
// return
|
||||
|
||||
// Initialize Amplitude
|
||||
amplitude.init(AMPLITUDE_API_KEY, {
|
||||
defaultTracking: {
|
||||
sessions: true,
|
||||
pageViews: true,
|
||||
formInteractions: true,
|
||||
fileDownloads: true,
|
||||
attribution: true,
|
||||
},
|
||||
ensureAmplitudeInitialized({
|
||||
sessionReplaySampleRate,
|
||||
})
|
||||
|
||||
// Add page name enrichment plugin to override page title with English name
|
||||
amplitude.add(pageNameEnrichmentPlugin())
|
||||
|
||||
// Add Session Replay plugin
|
||||
const sessionReplay = sessionReplayPlugin({
|
||||
sampleRate: sessionReplaySampleRate,
|
||||
})
|
||||
amplitude.add(sessionReplay)
|
||||
}, [])
|
||||
}, [sessionReplaySampleRate])
|
||||
|
||||
// This is a client component that renders nothing
|
||||
return null
|
||||
|
||||
@ -3,6 +3,7 @@ import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AmplitudeProvider from '../AmplitudeProvider'
|
||||
import { resetAmplitudeInitializationForTests } from '../init'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
AMPLITUDE_API_KEY: 'test-api-key',
|
||||
@ -35,6 +36,7 @@ describe('AmplitudeProvider', () => {
|
||||
vi.clearAllMocks()
|
||||
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
resetAmplitudeInitializationForTests()
|
||||
})
|
||||
|
||||
describe('Component', () => {
|
||||
@ -46,6 +48,17 @@ describe('AmplitudeProvider', () => {
|
||||
expect(amplitude.add).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not re-initialize amplitude on remount', () => {
|
||||
const { unmount } = render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
|
||||
|
||||
unmount()
|
||||
render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
|
||||
|
||||
expect(amplitude.init).toHaveBeenCalledTimes(1)
|
||||
expect(sessionReplayPlugin).toHaveBeenCalledTimes(1)
|
||||
expect(amplitude.add).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not initialize amplitude when disabled', () => {
|
||||
mockConfig.AMPLITUDE_API_KEY = ''
|
||||
render(<AmplitudeProvider />)
|
||||
|
||||
61
web/app/components/base/amplitude/__tests__/init.spec.ts
Normal file
61
web/app/components/base/amplitude/__tests__/init.spec.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ensureAmplitudeInitialized, resetAmplitudeInitializationForTests } from '../init'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
AMPLITUDE_API_KEY: 'test-api-key',
|
||||
IS_CLOUD_EDITION: true,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get AMPLITUDE_API_KEY() {
|
||||
return mockConfig.AMPLITUDE_API_KEY
|
||||
},
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.IS_CLOUD_EDITION
|
||||
},
|
||||
get isAmplitudeEnabled() {
|
||||
return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@amplitude/analytics-browser', () => ({
|
||||
init: vi.fn(),
|
||||
add: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@amplitude/plugin-session-replay-browser', () => ({
|
||||
sessionReplayPlugin: vi.fn(() => ({ name: 'session-replay' })),
|
||||
}))
|
||||
|
||||
describe('amplitude init helper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
resetAmplitudeInitializationForTests()
|
||||
})
|
||||
|
||||
describe('ensureAmplitudeInitialized', () => {
|
||||
it('should initialize amplitude only once across repeated calls', () => {
|
||||
ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.8 })
|
||||
ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.2 })
|
||||
|
||||
expect(amplitude.init).toHaveBeenCalledTimes(1)
|
||||
expect(sessionReplayPlugin).toHaveBeenCalledTimes(1)
|
||||
expect(sessionReplayPlugin).toHaveBeenCalledWith({ sampleRate: 0.8 })
|
||||
expect(amplitude.add).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should skip initialization when amplitude is disabled', () => {
|
||||
mockConfig.AMPLITUDE_API_KEY = ''
|
||||
|
||||
ensureAmplitudeInitialized()
|
||||
|
||||
expect(amplitude.init).not.toHaveBeenCalled()
|
||||
expect(sessionReplayPlugin).not.toHaveBeenCalled()
|
||||
expect(amplitude.add).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
82
web/app/components/base/amplitude/init.ts
Normal file
82
web/app/components/base/amplitude/init.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config'
|
||||
|
||||
export type AmplitudeInitializationOptions = {
|
||||
sessionReplaySampleRate?: number
|
||||
}
|
||||
|
||||
let isAmplitudeInitialized = false
|
||||
|
||||
// Map URL pathname to English page name for consistent Amplitude tracking
|
||||
const getEnglishPageName = (pathname: string): string => {
|
||||
// Remove leading slash and get the first segment
|
||||
const segments = pathname.replace(/^\//, '').split('/')
|
||||
const firstSegment = segments[0] || 'home'
|
||||
|
||||
const pageNameMap: Record<string, string> = {
|
||||
'': 'Home',
|
||||
'apps': 'Studio',
|
||||
'datasets': 'Knowledge',
|
||||
'explore': 'Explore',
|
||||
'tools': 'Tools',
|
||||
'account': 'Account',
|
||||
'signin': 'Sign In',
|
||||
'signup': 'Sign Up',
|
||||
}
|
||||
|
||||
return pageNameMap[firstSegment] || firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1)
|
||||
}
|
||||
|
||||
// Enrichment plugin to override page title with English name for page view events
|
||||
const createPageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
|
||||
return {
|
||||
name: 'page-name-enrichment',
|
||||
type: 'enrichment',
|
||||
setup: async () => undefined,
|
||||
execute: async (event: amplitude.Types.Event) => {
|
||||
// Only modify page view events
|
||||
if (event.event_type === '[Amplitude] Page Viewed' && event.event_properties) {
|
||||
/* v8 ignore next @preserve */
|
||||
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
|
||||
event.event_properties['[Amplitude] Page Title'] = getEnglishPageName(pathname)
|
||||
}
|
||||
return event
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const ensureAmplitudeInitialized = ({
|
||||
sessionReplaySampleRate = 0.5,
|
||||
}: AmplitudeInitializationOptions = {}) => {
|
||||
if (!isAmplitudeEnabled || isAmplitudeInitialized)
|
||||
return
|
||||
|
||||
isAmplitudeInitialized = true
|
||||
|
||||
try {
|
||||
amplitude.init(AMPLITUDE_API_KEY, {
|
||||
defaultTracking: {
|
||||
sessions: true,
|
||||
pageViews: true,
|
||||
formInteractions: true,
|
||||
fileDownloads: true,
|
||||
attribution: true,
|
||||
},
|
||||
})
|
||||
|
||||
amplitude.add(createPageNameEnrichmentPlugin())
|
||||
amplitude.add(sessionReplayPlugin({
|
||||
sampleRate: sessionReplaySampleRate,
|
||||
}))
|
||||
}
|
||||
catch (error) {
|
||||
isAmplitudeInitialized = false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Only used by unit tests to reset module-scoped initialization state.
|
||||
export const resetAmplitudeInitializationForTests = () => {
|
||||
isAmplitudeInitialized = false
|
||||
}
|
||||
@ -2,8 +2,9 @@ import type { i18n } from 'i18next'
|
||||
import type { ChatConfig } from '../../types'
|
||||
import type { ChatWithHistoryContextValue } from '../context'
|
||||
import type { AppData, AppMeta } from '@/models/share'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as ReactI18next from 'react-i18next'
|
||||
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { useChatWithHistoryContext } from '../context'
|
||||
import HeaderInMobile from '../header-in-mobile'
|
||||
|
||||
@ -2,7 +2,8 @@ import type { RefObject } from 'react'
|
||||
import type { ChatConfig } from '../../types'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useChatWithHistory } from '../hooks'
|
||||
|
||||
@ -1,23 +1,18 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { ChatWithHistoryContextValue } from '../../context'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import * as ReactI18next from 'react-i18next'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { useChatWithHistoryContext } from '../../context'
|
||||
import Sidebar from '../index'
|
||||
import RenameModal from '../rename-modal'
|
||||
|
||||
// Type for mocking the global public store selector
|
||||
type GlobalPublicStoreMock = {
|
||||
systemFeatures: {
|
||||
branding: {
|
||||
enabled: boolean
|
||||
workspace_logo: string | null
|
||||
}
|
||||
}
|
||||
setSystemFeatures?: (features: unknown) => void
|
||||
}
|
||||
let mockBranding: { enabled: boolean, workspace_logo: string } = { enabled: false, workspace_logo: '' }
|
||||
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: { branding: { ...mockBranding } },
|
||||
})
|
||||
|
||||
function mockUseTranslationWithEmptyKeys(emptyKeys: string[]) {
|
||||
const originalUseTranslation = ReactI18next.useTranslation
|
||||
@ -38,19 +33,6 @@ function mockUseTranslationWithEmptyKeys(emptyKeys: string[]) {
|
||||
})
|
||||
}
|
||||
|
||||
// Helper to create properly-typed mock store state
|
||||
function createMockStoreState(overrides: Partial<GlobalPublicStoreMock>): GlobalPublicStoreMock {
|
||||
return {
|
||||
systemFeatures: {
|
||||
branding: {
|
||||
enabled: false,
|
||||
workspace_logo: null,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Mock List to allow us to trigger operations
|
||||
vi.mock('../list', () => ({
|
||||
default: ({ list, onOperate, title, isPin }: { list: Array<{ id: string, name: string }>, onOperate: (type: string, item: { id: string, name: string }) => void, title?: string, isPin?: boolean }) => (
|
||||
@ -74,18 +56,6 @@ vi.mock('../../context', () => ({
|
||||
useChatWithHistoryContext: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock global public store
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(selector => selector({
|
||||
systemFeatures: {
|
||||
branding: {
|
||||
enabled: false,
|
||||
workspace_logo: null,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
@ -139,8 +109,8 @@ describe('Sidebar Index', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockBranding = { enabled: false, workspace_logo: '' }
|
||||
vi.mocked(useChatWithHistoryContext).mockReturnValue(mockContextValue)
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector(createMockStoreState({}) as never))
|
||||
})
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
@ -658,17 +628,7 @@ describe('Sidebar Index', () => {
|
||||
})
|
||||
|
||||
it('should use system branding logo when enabled', () => {
|
||||
const mockStoreState = createMockStoreState({
|
||||
systemFeatures: {
|
||||
branding: {
|
||||
enabled: true,
|
||||
workspace_logo: 'http://example.com/workspace-logo.png',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vi.mocked(useGlobalPublicStore).mockClear()
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector(mockStoreState as never))
|
||||
mockBranding = { enabled: true, workspace_logo: 'http://example.com/workspace-logo.png' }
|
||||
|
||||
vi.mocked(useChatWithHistoryContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
RiExpandRightLine,
|
||||
RiLayoutLeft2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
@ -26,7 +27,7 @@ import List from '@/app/components/base/chat/chat-with-history/sidebar/list'
|
||||
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useChatWithHistoryContext } from '../context'
|
||||
|
||||
type Props = {
|
||||
@ -55,7 +56,7 @@ const Sidebar = ({ isPanel, panelVisible }: Props) => {
|
||||
isResponding,
|
||||
} = useChatWithHistoryContext()
|
||||
const isSidebarCollapsed = sidebarCollapseState
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
|
||||
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
|
||||
|
||||
|
||||
@ -1,14 +1,20 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { ReactElement, RefObject } from 'react'
|
||||
import type { ChatConfig } from '../../types'
|
||||
import type { AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { useEmbeddedChatbot } from '../hooks'
|
||||
import EmbeddedChatbot from '../index'
|
||||
|
||||
let mockBrandingWorkspaceLogo = ''
|
||||
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: {
|
||||
branding: { enabled: true, workspace_logo: mockBrandingWorkspaceLogo },
|
||||
},
|
||||
})
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useEmbeddedChatbot: vi.fn(),
|
||||
}))
|
||||
@ -26,10 +32,6 @@ vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../chat-wrapper', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>chat area</div>,
|
||||
@ -125,19 +127,9 @@ const createHookReturn = (overrides: Partial<EmbeddedChatbotHookReturn> = {}): E
|
||||
describe('EmbeddedChatbot index', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockBrandingWorkspaceLogo = ''
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn())
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: true,
|
||||
workspace_logo: '',
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
})
|
||||
|
||||
describe('Loading and chat content', () => {
|
||||
@ -159,17 +151,7 @@ describe('EmbeddedChatbot index', () => {
|
||||
|
||||
describe('Powered by branding', () => {
|
||||
it('should show workspace logo on mobile when branding is enabled', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: true,
|
||||
workspace_logo: 'https://example.com/workspace-logo.png',
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
mockBrandingWorkspaceLogo = 'https://example.com/workspace-logo.png'
|
||||
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
|
||||
@ -1,30 +1,25 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { EmbeddedChatbotContextValue } from '../../context'
|
||||
import type { AppData } from '@/models/share'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { InstallationScope, LicenseStatus } from '@/types/feature'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { useEmbeddedChatbotContext } from '../../context'
|
||||
import Header from '../index'
|
||||
|
||||
let mockBranding = { enabled: true, workspace_logo: '' }
|
||||
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: { branding: { ...mockBranding } },
|
||||
})
|
||||
|
||||
vi.mock('../../context', () => ({
|
||||
useEmbeddedChatbotContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown', () => ({
|
||||
default: () => <div data-testid="view-form-dropdown" />,
|
||||
}))
|
||||
|
||||
type GlobalPublicStoreMock = {
|
||||
systemFeatures: SystemFeatures
|
||||
setSystemFeatures: (systemFeatures: SystemFeatures) => void
|
||||
}
|
||||
|
||||
describe('EmbeddedChatbot Header', () => {
|
||||
const defaultAppData: AppData = {
|
||||
app_id: 'test-app-id',
|
||||
@ -47,48 +42,6 @@ describe('EmbeddedChatbot Header', () => {
|
||||
allInputsHidden: false,
|
||||
}
|
||||
|
||||
const defaultSystemFeatures: SystemFeatures = {
|
||||
app_dsl_version: '',
|
||||
trial_models: [],
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
sso_enforced_for_signin: false,
|
||||
sso_enforced_for_signin_protocol: '',
|
||||
sso_enforced_for_web: false,
|
||||
sso_enforced_for_web_protocol: '',
|
||||
enable_marketplace: false,
|
||||
enable_change_email: false,
|
||||
enable_email_code_login: false,
|
||||
enable_email_password_login: false,
|
||||
enable_social_oauth_login: false,
|
||||
is_allow_create_workspace: false,
|
||||
is_allow_register: false,
|
||||
is_email_setup: false,
|
||||
license: {
|
||||
status: LicenseStatus.NONE,
|
||||
expired_at: '',
|
||||
},
|
||||
branding: {
|
||||
enabled: true,
|
||||
workspace_logo: '',
|
||||
login_page_logo: '',
|
||||
favicon: '',
|
||||
application_title: '',
|
||||
},
|
||||
webapp_auth: {
|
||||
enabled: false,
|
||||
allow_sso: false,
|
||||
sso_config: { protocol: '' },
|
||||
allow_email_code_login: false,
|
||||
allow_email_password_login: false,
|
||||
},
|
||||
enable_collaboration_mode: false,
|
||||
enable_trial_app: false,
|
||||
enable_explore_banner: false,
|
||||
}
|
||||
|
||||
const setupIframe = () => {
|
||||
const mockPostMessage = vi.fn()
|
||||
const mockTop = { postMessage: mockPostMessage }
|
||||
@ -100,11 +53,8 @@ describe('EmbeddedChatbot Header', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockBranding = { enabled: true, workspace_logo: '' }
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(defaultContext as EmbeddedChatbotContextValue)
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
|
||||
systemFeatures: defaultSystemFeatures,
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
|
||||
Object.defineProperty(window, 'self', { value: window, configurable: true })
|
||||
Object.defineProperty(window, 'top', { value: window, configurable: true })
|
||||
@ -149,16 +99,7 @@ describe('EmbeddedChatbot Header', () => {
|
||||
})
|
||||
|
||||
it('should render workspace logo when branding is enabled and logo exists', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
workspace_logo: 'https://example.com/workspace.png',
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
mockBranding = { enabled: true, workspace_logo: 'https://example.com/workspace.png' }
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
@ -167,32 +108,13 @@ describe('EmbeddedChatbot Header', () => {
|
||||
})
|
||||
|
||||
it('should render Dify logo by default when branding enabled is true but no logo provided', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: true,
|
||||
workspace_logo: '',
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
mockBranding = { enabled: true, workspace_logo: '' }
|
||||
render(<Header title="Test Chatbot" />)
|
||||
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Dify logo when branding is disabled', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
mockBranding = { enabled: false, workspace_logo: '' }
|
||||
render(<Header title="Test Chatbot" />)
|
||||
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Theme } from '../theme/theme-context'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -9,7 +10,7 @@ import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { isClient } from '@/utils/client'
|
||||
import {
|
||||
useEmbeddedChatbotContext,
|
||||
@ -44,7 +45,7 @@ const Header: FC<IHeaderProps> = ({
|
||||
const [parentOrigin, setParentOrigin] = useState('')
|
||||
const [showToggleExpandButton, setShowToggleExpandButton] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
const handleMessageReceived = useCallback((event: MessageEvent) => {
|
||||
let currentParentOrigin = parentOrigin
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { AppData } from '@/models/share'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
useEffect,
|
||||
} from 'react'
|
||||
@ -10,10 +11,10 @@ import Header from '@/app/components/base/chat/embedded-chatbot/header'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import {
|
||||
EmbeddedChatbotContext,
|
||||
useEmbeddedChatbotContext,
|
||||
@ -34,7 +35,7 @@ const Chatbot = () => {
|
||||
themeBuilder,
|
||||
} = useEmbeddedChatbotContext()
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
const customConfig = appData?.custom_config
|
||||
const site = appData?.site
|
||||
|
||||
@ -157,18 +157,6 @@ describe('NewFeaturePanel', () => {
|
||||
expect(screen.queryByText(/feature\.fileUpload\.title/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/feature\.imageUpload\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show file upload tip in chat mode with showFileUpload', () => {
|
||||
renderPanel({ isChatMode: true, showFileUpload: true })
|
||||
|
||||
expect(screen.getByText(/common\.fileUploadTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show image upload legacy tip in non-chat mode with showFileUpload', () => {
|
||||
renderPanel({ isChatMode: false, showFileUpload: true })
|
||||
|
||||
expect(screen.getByText(/common\.ImageUploadLegacyTip/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MoreLikeThis Feature', () => {
|
||||
@ -204,12 +192,4 @@ describe('NewFeaturePanel', () => {
|
||||
expect(screen.queryByText(/feature\.annotation\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should not show file upload tip when showFileUpload is false', () => {
|
||||
renderPanel({ isChatMode: true, showFileUpload: false })
|
||||
|
||||
expect(screen.queryByText(/common\.fileUploadTip/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -186,7 +186,7 @@ describe('OpeningSettingModal', () => {
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onCancel when close icon receives non-action key', async () => {
|
||||
it('should call onCancel when Escape is pressed on the dialog close control', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
@ -200,7 +200,7 @@ describe('OpeningSettingModal', () => {
|
||||
closeButton.focus()
|
||||
fireEvent.keyDown(closeButton, { key: 'Escape' })
|
||||
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onSave with updated data when save is clicked', async () => {
|
||||
@ -257,6 +257,26 @@ describe('OpeningSettingModal', () => {
|
||||
expect(allInputs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should focus a new suggested question without destructive styling', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/variableConfig\.addOption/))
|
||||
|
||||
const newInput = screen.getAllByPlaceholderText('appDebug.openingStatement.openingQuestionPlaceholder')
|
||||
.find(input => (input as HTMLInputElement).value === '') as HTMLInputElement
|
||||
const questionRow = newInput.parentElement
|
||||
|
||||
expect(newInput).toHaveFocus()
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-destructive')
|
||||
expect(questionRow).toHaveClass('border-components-input-border-active')
|
||||
})
|
||||
|
||||
it('should delete a suggested question via save verification', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
@ -334,7 +354,39 @@ describe('OpeningSettingModal', () => {
|
||||
)
|
||||
|
||||
// Count is displayed as "2/10" across child elements
|
||||
expect(screen.getByText(/openingStatement\.openingQuestion/)).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.openingStatement.openingQuestion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render separate opener and question sections', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('opener-input-section')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('opener-questions-section')).toBeInTheDocument()
|
||||
expect(screen.getByText(/openingStatement\.editorTitle/)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('opening-questions-tooltip')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/openingStatement\.openingQuestionDescription/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the opening questions description in a tooltip', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(screen.getByTestId('opening-questions-tooltip'))
|
||||
})
|
||||
|
||||
expect(screen.getByText(/openingStatement\.openingQuestionDescription/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onAutoAddPromptVariable when confirm add is clicked', async () => {
|
||||
@ -540,7 +592,9 @@ describe('OpeningSettingModal', () => {
|
||||
|
||||
const editor = getPromptEditor()
|
||||
expect(editor.textContent?.trim()).toBe('')
|
||||
expect(screen.getByText('appDebug.openingStatement.placeholder')).toBeInTheDocument()
|
||||
const openerSection = screen.getByTestId('opener-input-section')
|
||||
expect(openerSection.textContent).toContain('appDebug.openingStatement.placeholderLine1')
|
||||
expect(openerSection.textContent).toContain('appDebug.openingStatement.placeholderLine2')
|
||||
})
|
||||
|
||||
it('should render with empty suggested questions when field is missing', async () => {
|
||||
|
||||
@ -102,7 +102,7 @@ const ConversationOpener = ({
|
||||
<>
|
||||
{!isHovering && (
|
||||
<div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">
|
||||
{opening.opening_statement || t('openingStatement.placeholder', { ns: 'appDebug' })}
|
||||
{opening.opening_statement || t('openingStatement.placeholderLine1', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
{isHovering && (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user