merge main

This commit is contained in:
JzoNg 2026-04-20 18:00:24 +08:00
commit 89200fc1ce
320 changed files with 3753 additions and 3443 deletions

View File

@ -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.

View File

@ -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

View File

@ -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?

View File

@ -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")

View File

@ -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")

View File

@ -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

View File

@ -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)

View File

@ -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):

View File

@ -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
View 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"

View File

@ -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:

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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()

View File

@ -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
View 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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 278,
"total": 281,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

View File

@ -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:"
}
}

View File

@ -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
View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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}`,

View File

@ -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}`,

View File

@ -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 = () => {

View File

@ -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} />)

View File

@ -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', () => {

View File

@ -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'

View File

@ -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>,

View File

@ -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'

View File

@ -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', () => {

View File

@ -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 = {

View File

@ -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', () => {

View File

@ -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()

View File

@ -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', () => {

View File

@ -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', () => {

View 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 }
}

View File

@ -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])

View File

@ -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,
]
}

View 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>
)
}

View File

@ -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>

View 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>
)
}

View File

@ -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')}>

View File

@ -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()

View File

@ -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 (
<>

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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')

View File

@ -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>

View File

@ -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 />

View File

@ -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)}`)

View File

@ -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')}>

View File

@ -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()
})
})

View File

@ -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 />
}

View File

@ -126,7 +126,7 @@ const AppInfoDetailPanel = ({
secondaryOperations={secondaryOperations}
/>
</div>
{appDetail.type !== AppTypeEnum.EVALUATION && (
{appDetail.workflow_kind !== AppTypeEnum.EVALUATION && (
<CardView
appId={appDetail.id}
isInPanel={true}

View File

@ -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'

View File

@ -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),

View File

@ -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)

View File

@ -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')
})
})

View File

@ -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" />

View File

@ -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()}
/>
</>
)
}

View File

@ -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"

View File

@ -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'

View File

@ -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(() => {

View File

@ -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,

View File

@ -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,

View File

@ -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()),

View File

@ -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()

View File

@ -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()

View File

@ -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'

View File

@ -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'

View File

@ -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(

View File

@ -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

View File

@ -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 />)

View 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()
})
})
})

View 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
}

View File

@ -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'

View File

@ -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'

View File

@ -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,

View File

@ -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)

View File

@ -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 />)

View File

@ -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()
})

View File

@ -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

View File

@ -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

View File

@ -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()
})
})
})

View File

@ -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 () => {

View File

@ -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