diff --git a/.agents/skills/frontend-testing/SKILL.md b/.agents/skills/frontend-testing/SKILL.md index 4da070bdbf..105c979c58 100644 --- a/.agents/skills/frontend-testing/SKILL.md +++ b/.agents/skills/frontend-testing/SKILL.md @@ -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. diff --git a/.agents/skills/frontend-testing/references/checklist.md b/.agents/skills/frontend-testing/references/checklist.md index 10b8fb66f9..99258498dd 100644 --- a/.agents/skills/frontend-testing/references/checklist.md +++ b/.agents/skills/frontend-testing/references/checklist.md @@ -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 diff --git a/.agents/skills/frontend-testing/references/mocking.md b/.agents/skills/frontend-testing/references/mocking.md index 5884830569..8c2f1c0c58 100644 --- a/.agents/skills/frontend-testing/references/mocking.md +++ b/.agents/skills/frontend-testing/references/mocking.md @@ -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', () => () =>
Loading
) -vi.mock('@langgenius/dify-ui/button', () => ({ children }: any) => ) +vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children }: any) => })) -// ✅ 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? diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 75a18a477a..e5758ab050 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -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") diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index 14ca27acbd..f1b900e7e6 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -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") diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index c9956501e2..9dbc34abac 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -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 diff --git a/api/core/mcp/client/streamable_client.py b/api/core/mcp/client/streamable_client.py index 5c3cd0d8f8..acba3e666b 100644 --- a/api/core/mcp/client/streamable_client.py +++ b/api/core/mcp/client/streamable_client.py @@ -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) diff --git a/api/core/tools/utils/message_transformer.py b/api/core/tools/utils/message_transformer.py index 81c85bc90d..50a67dd70f 100644 --- a/api/core/tools/utils/message_transformer.py +++ b/api/core/tools/utils/message_transformer.py @@ -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): diff --git a/api/libs/token.py b/api/libs/token.py index a34db70764..5b043465ac 100644 --- a/api/libs/token.py +++ b/api/libs/token.py @@ -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: diff --git a/api/libs/url_utils.py b/api/libs/url_utils.py new file mode 100644 index 0000000000..adcac3add0 --- /dev/null +++ b/api/libs/url_utils.py @@ -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" diff --git a/api/models/model.py b/api/models/model.py index 8eabf45363..930f395aa7 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -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: diff --git a/api/services/app_service.py b/api/services/app_service.py index 038c59633a..a046b909b3 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -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() diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py index 15dec06311..18755ef012 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py @@ -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 diff --git a/api/tests/test_containers_integration_tests/services/test_app_service.py b/api/tests/test_containers_integration_tests/services/test_app_service.py index fa57dd4a6f..b695ae9fd9 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_service.py @@ -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 diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py index 94d6c17915..9465936f28 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py @@ -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): diff --git a/api/tests/unit_tests/core/mcp/client/test_streamable_http.py b/api/tests/unit_tests/core/mcp/client/test_streamable_http.py index 81f8da9a62..bbbffa2e69 100644 --- a/api/tests/unit_tests/core/mcp/client/test_streamable_http.py +++ b/api/tests/unit_tests/core/mcp/client/test_streamable_http.py @@ -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() diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 3cd5e45320..dab0b4d9fc 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md new file mode 100644 index 0000000000..5e4e439e5f --- /dev/null +++ b/packages/dify-ui/README.md @@ -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 + +
{children}
+ +``` + +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 diff --git a/packages/dify-ui/src/dialog/index.tsx b/packages/dify-ui/src/dialog/index.tsx index 517d6cc9fc..24c5bcc463 100644 --- a/packages/dify-ui/src/dialog/index.tsx +++ b/packages/dify-ui/src/dialog/index.tsx @@ -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 diff --git a/packages/dify-ui/src/scroll-area/index.stories.tsx b/packages/dify-ui/src/scroll-area/index.stories.tsx index e1f8f9cfb5..433817948f 100644 --- a/packages/dify-ui/src/scroll-area/index.stories.tsx +++ b/packages/dify-ui/src/scroll-area/index.stories.tsx @@ -174,7 +174,7 @@ const StickyListPane = () => (
Operational queue
-

The scrollbar is still the shared base/ui primitive, while the pane adds sticky structure and a viewport mask.

+

The scrollbar is still the shared dify-ui primitive, while the pane adds sticky structure and a viewport mask.

24 items diff --git a/packages/dify-ui/src/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx index 8bdf4417e0..edbdacd203 100644 --- a/packages/dify-ui/src/toast/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/toast/__tests__/index.spec.tsx @@ -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, diff --git a/packages/iconify-collections/assets/vender/line/others/dhs.svg b/packages/iconify-collections/assets/vender/line/others/dhs.svg new file mode 100644 index 0000000000..54e8eff8c2 --- /dev/null +++ b/packages/iconify-collections/assets/vender/line/others/dhs.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/assets/vender/line/others/dvs.svg b/packages/iconify-collections/assets/vender/line/others/dvs.svg new file mode 100644 index 0000000000..3b1c9f2f4c --- /dev/null +++ b/packages/iconify-collections/assets/vender/line/others/dvs.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/assets/vender/line/others/evaluation.svg b/packages/iconify-collections/assets/vender/line/others/evaluation.svg new file mode 100644 index 0000000000..3856b8b176 --- /dev/null +++ b/packages/iconify-collections/assets/vender/line/others/evaluation.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/custom-public/icons.json b/packages/iconify-collections/custom-public/icons.json index 7c7d110be8..13d8400b89 100644 --- a/packages/iconify-collections/custom-public/icons.json +++ b/packages/iconify-collections/custom-public/icons.json @@ -1,414 +1,414 @@ { "prefix": "custom-public", - "lastModified": 1776313052, + "lastModified": 1776670621, "icons": { "avatar-user": { - "body": "", + "body": "", "width": 512, "height": 512 }, "billing-ar-cube-1": { - "body": "", + "body": "", "width": 28 }, "billing-asterisk": { - "body": "", + "body": "", "width": 28 }, "billing-aws-marketplace-dark": { - "body": "", + "body": "", "width": 126, "height": 25 }, "billing-aws-marketplace-light": { - "body": "", + "body": "", "width": 126, "height": 24 }, "billing-azure": { - "body": "", + "body": "", "width": 21, "height": 20 }, "billing-buildings": { - "body": "" + "body": "" }, "billing-diamond": { - "body": "" + "body": "" }, "billing-google-cloud": { - "body": "", + "body": "", "width": 22, "height": 18 }, "billing-group-2": { - "body": "" + "body": "" }, "billing-keyframe": { - "body": "" + "body": "" }, "billing-sparkles-soft": { - "body": "", + "body": "", "width": 13, "height": 13 }, "common-d": { - "body": "" + "body": "" }, "common-diagonal-dividing-line": { - "body": "", + "body": "", "width": 7, "height": 20 }, "common-dify": { - "body": "", + "body": "", "width": 50, "height": 26 }, "common-enter-key": { - "body": "" + "body": "" }, "common-gdpr": { - "body": "", + "body": "", "width": 23, "height": 28 }, "common-github": { - "body": "", + "body": "", "width": 18, "height": 18 }, "common-highlight": { - "body": "", + "body": "", "width": 46, "height": 24 }, "common-iso": { - "body": "", + "body": "", "width": 64, "height": 64 }, "common-line-3": { - "body": "", + "body": "", "width": 5, "height": 12 }, "common-lock": { - "body": "" + "body": "" }, "common-message-chat-square": { - "body": "" + "body": "" }, "common-multi-path-retrieval": { - "body": "", + "body": "", "width": 36, "height": 36 }, "common-n-to-1-retrieval": { - "body": "", + "body": "", "width": 36, "height": 36 }, "common-notion": { - "body": "", + "body": "", "width": 20, "height": 20 }, "common-soc2": { - "body": "", + "body": "", "width": 28, "height": 28 }, "common-sparkles-soft": { - "body": "", + "body": "", "width": 14, "height": 14 }, "common-sparkles-soft-accent": { - "body": "" + "body": "" }, "education-triangle": { - "body": "", + "body": "", "height": 22 }, "files-csv": { - "body": "" + "body": "" }, "files-doc": { - "body": "" + "body": "" }, "files-docx": { - "body": "" + "body": "" }, "files-html": { - "body": "" + "body": "" }, "files-json": { - "body": "" + "body": "" }, "files-md": { - "body": "" + "body": "" }, "files-pdf": { - "body": "" + "body": "" }, "files-txt": { - "body": "" + "body": "" }, "files-unknown": { - "body": "" + "body": "" }, "files-xlsx": { - "body": "", + "body": "", "width": 24, "height": 26 }, "files-yaml": { - "body": "", + "body": "", "width": 24, "height": 26 }, "knowledge-file": { - "body": "", + "body": "", "width": 16, "height": 16 }, "knowledge-option-card-effect-blue": { - "body": "", + "body": "", "width": 214, "height": 124 }, "knowledge-option-card-effect-blue-light": { - "body": "", + "body": "", "width": 212, "height": 74 }, "knowledge-option-card-effect-orange": { - "body": "" + "body": "" }, "knowledge-option-card-effect-purple": { - "body": "" + "body": "" }, "knowledge-option-card-effect-teal": { - "body": "", + "body": "", "width": 212, "height": 92 }, "knowledge-selection-mod": { - "body": "", + "body": "", "width": 10, "height": 10 }, "knowledge-watercrawl": { - "body": "", + "body": "", "width": 500, "height": 500 }, "knowledge-dataset-card-external-knowledge-base": { - "body": "" + "body": "" }, "knowledge-dataset-card-general": { - "body": "" + "body": "" }, "knowledge-dataset-card-graph": { - "body": "" + "body": "" }, "knowledge-dataset-card-parent-child": { - "body": "" + "body": "" }, "knowledge-dataset-card-qa": { - "body": "" + "body": "" }, "knowledge-online-drive-buckets-blue": { - "body": "", + "body": "", "height": 21 }, "knowledge-online-drive-buckets-gray": { - "body": "", + "body": "", "width": 18 }, "knowledge-online-drive-folder": { - "body": "" + "body": "" }, "llm-anthropic": { - "body": "" + "body": "" }, "llm-anthropic-dark": { - "body": "", + "body": "", "width": 90, "height": 10 }, "llm-anthropic-light": { - "body": "", + "body": "", "width": 90, "height": 10 }, "llm-anthropic-short-light": { - "body": "", + "body": "", "width": 40, "height": 40 }, "llm-anthropic-text": { - "body": "", + "body": "", "width": 90, "height": 20 }, "llm-azure-openai-service": { - "body": "", + "body": "", "width": 56 }, "llm-azure-openai-service-text": { - "body": "", + "body": "", "width": 212 }, "llm-azureai": { - "body": "" + "body": "" }, "llm-azureai-text": { - "body": "", + "body": "", "width": 92 }, "llm-baichuan": { - "body": "" + "body": "" }, "llm-baichuan-text": { - "body": "", + "body": "", "width": 130 }, "llm-chatglm": { - "body": "" + "body": "" }, "llm-chatglm-text": { - "body": "", + "body": "", "width": 100 }, "llm-cohere": { - "body": "", + "body": "", "width": 22, "height": 22 }, "llm-cohere-text": { - "body": "", + "body": "", "width": 120 }, "llm-deepseek": { - "body": "", + "body": "", "width": 40, "height": 40 }, "llm-gemini": { - "body": "", + "body": "", "width": 40, "height": 40 }, "llm-gpt-3": { - "body": "" + "body": "" }, "llm-gpt-4": { - "body": "" + "body": "" }, "llm-grok": { - "body": "", + "body": "", "width": 40, "height": 40 }, "llm-huggingface": { - "body": "" + "body": "" }, "llm-huggingface-text": { - "body": "", + "body": "", "width": 120 }, "llm-huggingface-text-hub": { - "body": "", + "body": "", "width": 151 }, "llm-iflytek-spark": { - "body": "" + "body": "" }, "llm-iflytek-spark-text": { - "body": "", + "body": "", "width": 150 }, "llm-iflytek-spark-text-cn": { - "body": "", + "body": "", "width": 84 }, "llm-jina": { - "body": "" + "body": "" }, "llm-jina-text": { - "body": "", + "body": "", "width": 58 }, "llm-microsoft": { - "body": "", + "body": "", "width": 21, "height": 22 }, "llm-openai-black": { - "body": "" + "body": "" }, "llm-openai-blue": { - "body": "" + "body": "" }, "llm-openai-green": { - "body": "" + "body": "" }, "llm-openai-teal": { - "body": "" + "body": "" }, "llm-openai-text": { - "body": "", + "body": "", "width": 52, "height": 20 }, "llm-openai-transparent": { - "body": "" + "body": "" }, "llm-openai-violet": { - "body": "" + "body": "" }, "llm-openai-yellow": { - "body": "" + "body": "" }, "llm-openllm": { - "body": "" + "body": "" }, "llm-openllm-text": { - "body": "", + "body": "", "width": 92, "height": 25 }, "llm-replicate": { - "body": "" + "body": "" }, "llm-replicate-text": { - "body": "", + "body": "", "width": 92 }, "llm-xorbits-inference": { - "body": "" + "body": "" }, "llm-xorbits-inference-text": { - "body": "", + "body": "", "width": 152 }, "llm-zhipuai": { - "body": "" + "body": "" }, "llm-zhipuai-text": { - "body": "", + "body": "", "width": 89, "height": 32 }, "llm-zhipuai-text-cn": { - "body": "", + "body": "", "width": 86, "height": 32 }, "model-checked": { - "body": "" + "body": "" }, "other-comment": { "body": "", @@ -416,164 +416,164 @@ "height": 12 }, "other-default-tool-icon": { - "body": "" + "body": "" }, "other-icon-3-dots": { - "body": "", + "body": "", "width": 16, "height": 16 }, "other-message-3-fill": { - "body": "" + "body": "" }, "other-row-struct": { - "body": "", + "body": "", "width": 624, "height": 48 }, "other-slack": { - "body": "", + "body": "", "width": 27, "height": 27 }, "other-teams": { - "body": "", + "body": "", "width": 28, "height": 28 }, "plugins-google": { - "body": "", + "body": "", "width": 24, "height": 24 }, "plugins-partner-dark": { - "body": "" + "body": "" }, "plugins-partner-light": { - "body": "" + "body": "" }, "plugins-verified-dark": { - "body": "" + "body": "" }, "plugins-verified-light": { - "body": "" + "body": "" }, "plugins-web-reader": { - "body": "", + "body": "", "width": 24, "height": 24 }, "plugins-wikipedia": { - "body": "", + "body": "", "width": 24, "height": 24 }, "thought-data-set": { - "body": "" + "body": "" }, "thought-loading": { - "body": "" + "body": "" }, "thought-search": { - "body": "" + "body": "" }, "thought-thought-list": { - "body": "" + "body": "" }, "thought-web-reader": { - "body": "" + "body": "" }, "tracing-aliyun-icon": { - "body": "", + "body": "", "width": 65 }, "tracing-aliyun-icon-big": { - "body": "", + "body": "", "width": 96, "height": 24 }, "tracing-arize-icon": { - "body": "" + "body": "" }, "tracing-arize-icon-big": { - "body": "", + "body": "", "width": 111, "height": 24 }, "tracing-databricks-icon": { - "body": "", + "body": "", "width": 100 }, "tracing-databricks-icon-big": { - "body": "", + "body": "", "width": 151, "height": 24 }, "tracing-langfuse-icon": { - "body": "" + "body": "" }, "tracing-langfuse-icon-big": { - "body": "", + "body": "", "width": 111, "height": 24 }, "tracing-langsmith-icon": { - "body": "", + "body": "", "width": 84, "height": 14 }, "tracing-langsmith-icon-big": { - "body": "", + "body": "", "width": 124, "height": 20 }, "tracing-mlflow-icon": { - "body": "", + "body": "", "width": 43 }, "tracing-mlflow-icon-big": { - "body": "", + "body": "", "width": 65, "height": 24 }, "tracing-opik-icon": { - "body": "", + "body": "", "width": 47.134 }, "tracing-opik-icon-big": { - "body": "", + "body": "", "width": 70.701, "height": 24 }, "tracing-phoenix-icon": { - "body": "" + "body": "" }, "tracing-phoenix-icon-big": { - "body": "", + "body": "", "width": 111, "height": 24 }, "tracing-tencent-icon": { - "body": "", + "body": "", "width": 80, "height": 18 }, "tracing-tencent-icon-big": { - "body": "", + "body": "", "width": 80, "height": 18 }, "tracing-tracing-icon": { - "body": "", + "body": "", "width": 20, "height": 20 }, "tracing-weave-icon": { - "body": "", + "body": "", "width": 120 }, "tracing-weave-icon-big": { - "body": "", + "body": "", "width": 120 } } diff --git a/packages/iconify-collections/custom-vender/icons.json b/packages/iconify-collections/custom-vender/icons.json index d588db650e..c8427ff479 100644 --- a/packages/iconify-collections/custom-vender/icons.json +++ b/packages/iconify-collections/custom-vender/icons.json @@ -1,1029 +1,1044 @@ { "prefix": "custom-vender", - "lastModified": 1776313052, + "lastModified": 1776670621, "icons": { "features-citations": { - "body": "" + "body": "" }, "features-content-moderation": { - "body": "" + "body": "" }, "features-document": { - "body": "" + "body": "" }, "features-folder-upload": { - "body": "" + "body": "" }, "features-love-message": { - "body": "" + "body": "" }, "features-message-fast": { - "body": "" + "body": "" }, "features-microphone-01": { - "body": "" + "body": "" }, "features-text-to-audio": { - "body": "" + "body": "" }, "features-virtual-assistant": { - "body": "" + "body": "" }, "features-vision": { - "body": "" + "body": "" }, "knowledge-add-chunks": { - "body": "", + "body": "", "width": 20, "height": 20 }, "knowledge-api-aggregate": { - "body": "", + "body": "", "width": 16 }, "knowledge-arrow-shape": { - "body": "", + "body": "", "width": 24, "height": 11 }, "knowledge-chunk": { - "body": "", + "body": "", "width": 10, "height": 10 }, "knowledge-collapse": { - "body": "", + "body": "", "width": 16 }, "knowledge-divider": { - "body": "", + "body": "", "width": 6, "height": 30 }, "knowledge-economic": { - "body": "", + "body": "", "height": 18 }, "knowledge-full-text-search": { - "body": "", + "body": "", "width": 15 }, "knowledge-general-chunk": { - "body": "", + "body": "", "height": 18 }, "knowledge-high-quality": { - "body": "", + "body": "", "height": 18 }, "knowledge-hybrid-search": { - "body": "", + "body": "", "width": 16 }, "knowledge-parent-child-chunk": { - "body": "", + "body": "", "height": 18 }, "knowledge-question-and-answer": { - "body": "", + "body": "", "height": 18 }, "knowledge-search-lines-sparkle": { - "body": "", + "body": "", "width": 16 }, "knowledge-search-menu": { - "body": "", + "body": "", "width": 32, "height": 33 }, "knowledge-vector-search": { - "body": "", + "body": "", "width": 16 }, "line-alertsAndFeedback-alert-triangle": { - "body": "" + "body": "" }, "line-alertsAndFeedback-thumbs-down": { - "body": "" + "body": "" }, "line-alertsAndFeedback-thumbs-up": { - "body": "" + "body": "" }, "line-alertsAndFeedback-warning": { - "body": "", + "body": "", "width": 12, "height": 12 }, "line-arrows-arrow-narrow-left": { - "body": "", + "body": "", "width": 17, "height": 16 }, "line-arrows-arrow-up-right": { - "body": "" + "body": "" }, "line-arrows-chevron-down-double": { - "body": "", + "body": "", "width": 12, "height": 13 }, "line-arrows-chevron-right": { - "body": "" + "body": "" }, "line-arrows-chevron-selector-vertical": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-arrows-iconr": { - "body": "" + "body": "" }, "line-arrows-refresh-ccw-01": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-arrows-refresh-cw-05": { - "body": "", + "body": "", "width": 16, "height": 16 }, "line-arrows-reverse-left": { - "body": "", + "body": "", "width": 16, "height": 16 }, "line-communication-ai-text": { - "body": "" + "body": "" }, "line-communication-chat-bot": { - "body": "" + "body": "" }, "line-communication-chat-bot-slim": { - "body": "", + "body": "", "width": 48, "height": 48 }, "line-communication-cute-robot": { - "body": "" + "body": "" }, "line-communication-message-check-remove": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-communication-message-fast-plus": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-development-artificial-brain": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-development-bar-chart-square-02": { - "body": "" + "body": "" }, "line-development-brackets-x": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-development-code-browser": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-development-container": { - "body": "" + "body": "" }, "line-development-database-01": { - "body": "", + "body": "", "width": 17 }, "line-development-database-03": { - "body": "" + "body": "" }, "line-development-file-heart-02": { - "body": "" + "body": "" }, "line-development-git-branch-01": { - "body": "" + "body": "" }, "line-development-prompt-engineering": { - "body": "" + "body": "" }, "line-development-puzzle-piece-01": { - "body": "" + "body": "" }, "line-development-terminal-square": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-development-variable": { - "body": "" + "body": "" }, "line-development-webhooks": { - "body": "" + "body": "" }, "line-editor-align-left": { - "body": "" + "body": "" }, "line-editor-bezier-curve-03": { - "body": "", + "body": "", "width": 12, "height": 12 }, "line-editor-collapse": { - "body": "", + "body": "", "width": 16, "height": 16 }, "line-editor-colors": { - "body": "" + "body": "" }, "line-editor-image-indent-left": { - "body": "" + "body": "" }, "line-editor-left-indent-02": { - "body": "" + "body": "" }, "line-editor-letter-spacing-01": { - "body": "" + "body": "" }, "line-editor-type-square": { - "body": "", + "body": "", "width": 12, "height": 12 }, "line-education-book-open-01": { - "body": "", + "body": "", "width": 12, "height": 12 }, "line-files-copy": { - "body": "" + "body": "" }, "line-files-copy-check": { - "body": "" + "body": "" }, "line-files-file-02": { - "body": "" + "body": "" }, "line-files-file-arrow-01": { - "body": "" + "body": "" }, "line-files-file-check-02": { - "body": "" + "body": "" }, "line-files-file-download-02": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-files-file-plus-01": { - "body": "" + "body": "" }, "line-files-file-plus-02": { - "body": "" + "body": "" }, "line-files-file-text": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-files-file-upload": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-files-folder": { - "body": "", + "body": "", "width": 14, "height": 14 }, "line-financeAndECommerce-balance": { - "body": "" + "body": "" }, "line-financeAndECommerce-coins-stacked-01": { - "body": "" + "body": "" }, "line-financeAndECommerce-credits-coin": { - "body": "", + "body": "", "width": 10, "height": 10 }, "line-financeAndECommerce-gold-coin": { - "body": "", + "body": "", "width": 16, "height": 16 }, "line-financeAndECommerce-receipt-list": { - "body": "" + "body": "" }, "line-financeAndECommerce-tag-01": { - "body": "", + "body": "", "width": 14, "height": 14 }, "line-financeAndECommerce-tag-03": { - "body": "", + "body": "", "width": 16, "height": 16 }, "line-general-at-sign": { - "body": "" + "body": "" }, "line-general-bookmark": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-general-check": { - "body": "" + "body": "" }, "line-general-check-done-01": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-general-checklist-square": { - "body": "", + "body": "", "width": 32, "height": 32 }, "line-general-code-assistant": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-general-dots-grid": { - "body": "", + "body": "", "width": 14, "height": 14 }, "line-general-edit-02": { - "body": "", + "body": "", "width": 14, "height": 14 }, "line-general-edit-04": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-general-edit-05": { - "body": "" + "body": "" }, "line-general-hash-02": { - "body": "", + "body": "", "width": 12, "height": 12 }, "line-general-info-circle": { - "body": "", + "body": "", "width": 12, "height": 12 }, "line-general-link-03": { - "body": "", + "body": "", "width": 17 }, "line-general-link-external-02": { - "body": "", + "body": "", "width": 12, "height": 12 }, "line-general-log-in-04": { - "body": "" + "body": "" }, "line-general-log-out-01": { - "body": "", + "body": "", "width": 14, "height": 14 }, "line-general-log-out-04": { - "body": "" + "body": "" }, "line-general-magic-edit": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-general-menu-01": { - "body": "" + "body": "" }, "line-general-pin-01": { - "body": "" + "body": "" }, "line-general-pin-02": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-general-plus-02": { - "body": "", + "body": "", "width": 10, "height": 10 }, "line-general-refresh": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-general-search-menu": { - "body": "", + "body": "", "width": 32, "height": 32 }, "line-general-settings-01": { - "body": "", + "body": "", "width": 14, "height": 14 }, "line-general-settings-04": { - "body": "", + "body": "", "width": 14, "height": 14 }, "line-general-target-04": { - "body": "", + "body": "", "width": 12, "height": 12 }, "line-general-upload-03": { - "body": "" + "body": "" }, "line-general-upload-cloud-01": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-general-x": { - "body": "" + "body": "" }, "line-images-image-plus": { - "body": "" + "body": "" }, "line-layout-align-left-01": { - "body": "" + "body": "" }, "line-layout-align-right-01": { - "body": "" + "body": "" }, "line-layout-grid-01": { - "body": "", + "body": "", "width": 17, "height": 16 }, "line-layout-layout-grid-02": { - "body": "" + "body": "" }, "line-mediaAndDevices-microphone-01": { - "body": "" + "body": "" }, "line-mediaAndDevices-play-circle": { - "body": "" + "body": "" }, "line-mediaAndDevices-sliders-h": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-mediaAndDevices-speaker": { - "body": "" + "body": "" }, "line-mediaAndDevices-stop": { - "body": "", + "body": "", "width": 12, "height": 12 }, "line-mediaAndDevices-stop-circle": { - "body": "", + "body": "", "width": 17 }, "line-others-bubble-x": { - "body": "" + "body": "" }, "line-others-colors": { - "body": "", + "body": "", "width": 14, "height": 14 }, + "line-others-dhs": { + "body": "", + "width": 18, + "height": 18 + }, "line-others-drag-handle": { - "body": "" + "body": "" + }, + "line-others-dvs": { + "body": "", + "width": 18, + "height": 18 }, "line-others-env": { - "body": "" + "body": "" + }, + "line-others-evaluation": { + "body": "", + "width": 18, + "height": 18 }, "line-others-global-variable": { - "body": "" + "body": "" }, "line-others-icon-3-dots": { - "body": "" + "body": "" }, "line-others-long-arrow-left": { - "body": "", + "body": "", "width": 21, "height": 8 }, "line-others-long-arrow-right": { - "body": "", + "body": "", "width": 26, "height": 8 }, "line-others-search-menu": { - "body": "", + "body": "", "width": 32, "height": 32 }, "line-others-tools": { - "body": "", + "body": "", "height": 17 }, "line-shapes-cube-outline": { - "body": "", + "body": "", "height": 17 }, "line-time-clock-fast-forward": { - "body": "", + "body": "", "width": 24, "height": 24 }, "line-time-clock-play": { - "body": "" + "body": "" }, "line-time-clock-play-slim": { - "body": "", + "body": "", "width": 32, "height": 32 }, "line-time-clock-refresh": { - "body": "", + "body": "", "width": 12, "height": 12 }, "line-users-user-01": { - "body": "" + "body": "" }, "line-users-users-01": { - "body": "" + "body": "" }, "line-weather-stars-02": { - "body": "", + "body": "", "width": 24, "height": 24 }, "other-anthropic-text": { - "body": "", + "body": "", "width": 90, "height": 20 }, "other-generator": { - "body": "" + "body": "" }, "other-group": { - "body": "", + "body": "", "height": 16 }, "other-hourglass-shape": { - "body": "", + "body": "", "width": 8 }, "other-mcp": { - "body": "", + "body": "", "width": 16, "height": 16 }, "other-no-tool-placeholder": { - "body": "", + "body": "", "width": 204, "height": 36 }, "other-openai": { - "body": "", + "body": "", "width": 80, "height": 22 }, "other-replay-line": { - "body": "", + "body": "", "width": 20, "height": 20 }, "other-square-checklist": { - "body": "", + "body": "", "width": 24, "height": 24 }, "pipeline-input-field": { - "body": "", + "body": "", "width": 16, "height": 16 }, "pipeline-pipeline-fill": { - "body": "" + "body": "" }, "pipeline-pipeline-line": { - "body": "" + "body": "" }, "plugin-box-sparkle-fill": { - "body": "", + "body": "", "width": 14, "height": 14 }, "plugin-left-corner": { - "body": "", + "body": "", "width": 13, "height": 20 }, "plugin-trigger": { - "body": "" + "body": "" }, "solid-FinanceAndECommerce-gold-coin": { - "body": "" + "body": "" }, "solid-FinanceAndECommerce-scales-02": { - "body": "" + "body": "" }, "solid-alertsAndFeedback-alert-triangle": { - "body": "", + "body": "", "width": 12, "height": 12 }, "solid-arrows-arrow-down-double-line": { - "body": "" + "body": "" }, "solid-arrows-arrow-down-round-fill": { - "body": "" + "body": "" }, "solid-arrows-arrow-up-double-line": { - "body": "" + "body": "" }, "solid-arrows-chevron-down": { - "body": "", + "body": "", "width": 24, "height": 24 }, "solid-arrows-high-priority": { - "body": "", + "body": "", "width": 24, "height": 24 }, "solid-communication-ai-text": { - "body": "" + "body": "" }, "solid-communication-bubble-text-mod": { - "body": "" + "body": "" }, "solid-communication-chat-bot": { - "body": "", + "body": "", "width": 13, "height": 12 }, "solid-communication-cute-robot": { - "body": "" + "body": "" }, "solid-communication-edit-list": { - "body": "" + "body": "" }, "solid-communication-list-sparkle": { - "body": "" + "body": "" }, "solid-communication-logic": { - "body": "" + "body": "" }, "solid-communication-message-dots-circle": { - "body": "" + "body": "" }, "solid-communication-message-fast": { - "body": "" + "body": "" }, "solid-communication-message-heart-circle": { - "body": "", + "body": "", "width": 16, "height": 16 }, "solid-communication-message-smile-square": { - "body": "", + "body": "", "width": 16, "height": 16 }, "solid-communication-send-03": { - "body": "", + "body": "", "width": 20, "height": 20 }, "solid-development-api-connection": { - "body": "", + "body": "", "width": 24, "height": 24 }, "solid-development-api-connection-mod": { - "body": "" + "body": "" }, "solid-development-bar-chart-square-02": { - "body": "" + "body": "" }, "solid-development-container": { - "body": "", + "body": "", "width": 17 }, "solid-development-database-02": { - "body": "", + "body": "", "width": 17 }, "solid-development-database-03": { - "body": "" + "body": "" }, "solid-development-file-heart-02": { - "body": "" + "body": "" }, "solid-development-pattern-recognition": { - "body": "", + "body": "", "width": 24, "height": 24 }, "solid-development-prompt-engineering": { - "body": "" + "body": "" }, "solid-development-puzzle-piece-01": { - "body": "", + "body": "", "width": 17 }, "solid-development-semantic": { - "body": "", + "body": "", "width": 24, "height": 24 }, "solid-development-terminal-square": { - "body": "", + "body": "", "width": 12, "height": 12 }, "solid-development-variable-02": { - "body": "", + "body": "", "width": 24, "height": 24 }, "solid-editor-brush-01": { - "body": "" + "body": "" }, "solid-editor-citations": { - "body": "", + "body": "", "width": 16, "height": 16 }, "solid-editor-colors": { - "body": "" + "body": "" }, "solid-editor-paragraph": { - "body": "" + "body": "" }, "solid-editor-type-square": { - "body": "" + "body": "" }, "solid-education-beaker-02": { - "body": "", + "body": "", "width": 12, "height": 12 }, "solid-education-bubble-text": { - "body": "" + "body": "" }, "solid-education-heart-02": { - "body": "" + "body": "" }, "solid-education-unblur": { - "body": "" + "body": "" }, "solid-files-file-05": { - "body": "" + "body": "" }, "solid-files-file-search-02": { - "body": "" + "body": "" }, "solid-files-file-zip": { - "body": "" + "body": "" }, "solid-files-folder": { - "body": "" + "body": "" }, "solid-general-answer-triangle": { - "body": "", + "body": "", "width": 8, "height": 12 }, "solid-general-arrow-down-round-fill": { - "body": "", + "body": "", "width": 16, "height": 16 }, "solid-general-check-circle": { - "body": "", + "body": "", "width": 16, "height": 16 }, "solid-general-check-done-01": { - "body": "" + "body": "" }, "solid-general-download-02": { - "body": "" + "body": "" }, "solid-general-edit-03": { - "body": "", + "body": "", "width": 12, "height": 12 }, "solid-general-edit-04": { - "body": "" + "body": "" }, "solid-general-eye": { - "body": "" + "body": "" }, "solid-general-github": { - "body": "", + "body": "", "width": 16, "height": 16 }, "solid-general-message-clock-circle": { - "body": "", + "body": "", "width": 16, "height": 16 }, "solid-general-plus-circle": { - "body": "" + "body": "" }, "solid-general-question-triangle": { - "body": "", + "body": "", "width": 8, "height": 12 }, "solid-general-search-md": { - "body": "" + "body": "" }, "solid-general-target-04": { - "body": "" + "body": "" }, "solid-general-tool-03": { - "body": "", + "body": "", "width": 16, "height": 16 }, "solid-general-x-circle": { - "body": "", + "body": "", "width": 16, "height": 16 }, "solid-general-zap-fast": { - "body": "", + "body": "", "width": 12, "height": 12 }, "solid-general-zap-narrow": { - "body": "", + "body": "", "width": 12, "height": 12 }, "solid-layout-grid-01": { - "body": "" + "body": "" }, "solid-mediaAndDevices-audio-support-icon": { - "body": "" + "body": "" }, "solid-mediaAndDevices-document-support-icon": { - "body": "" + "body": "" }, "solid-mediaAndDevices-magic-box": { - "body": "" + "body": "" }, "solid-mediaAndDevices-magic-eyes": { - "body": "" + "body": "" }, "solid-mediaAndDevices-magic-wand": { - "body": "" + "body": "" }, "solid-mediaAndDevices-microphone-01": { - "body": "", + "body": "", "width": 16, "height": 16 }, "solid-mediaAndDevices-play": { - "body": "" + "body": "" }, "solid-mediaAndDevices-robot": { - "body": "" + "body": "" }, "solid-mediaAndDevices-sliders-02": { - "body": "", + "body": "", "width": 24, "height": 24 }, "solid-mediaAndDevices-speaker": { - "body": "", + "body": "", "width": 16, "height": 16 }, "solid-mediaAndDevices-stop-circle": { - "body": "", + "body": "", "width": 20, "height": 20 }, "solid-mediaAndDevices-video-support-icon": { - "body": "" + "body": "" }, "solid-security-lock-01": { - "body": "", + "body": "", "width": 12, "height": 12 }, "solid-shapes-corner": { - "body": "", + "body": "", "width": 13, "height": 20 }, "solid-shapes-star-04": { - "body": "", + "body": "", "width": 11, "height": 10 }, "solid-shapes-star-06": { - "body": "" + "body": "" }, "solid-users-user-01": { - "body": "" + "body": "" }, "solid-users-user-edit-02": { - "body": "", + "body": "", "width": 14, "height": 14 }, "solid-users-users-01": { - "body": "" + "body": "" }, "solid-users-users-plus": { - "body": "", + "body": "", "width": 24, "height": 24 }, "system-auto-update-line": { - "body": "", + "body": "", "width": 24, "height": 24 }, "workflow-agent": { - "body": "", + "body": "", "width": 16, "height": 16 }, "workflow-answer": { - "body": "" + "body": "" }, "workflow-api-aggregate": { - "body": "", + "body": "", "width": 16, "height": 16 }, "workflow-assigner": { - "body": "", + "body": "", "width": 16, "height": 16 }, "workflow-asterisk": { - "body": "" + "body": "" }, "workflow-calendar-check-line": { - "body": "" + "body": "" }, "workflow-code": { - "body": "" + "body": "" }, "workflow-datasource": { - "body": "" + "body": "" }, "workflow-docs-extractor": { - "body": "", + "body": "", "width": 16, "height": 16 }, "workflow-end": { - "body": "" + "body": "" }, "workflow-home": { - "body": "" + "body": "" }, "workflow-http": { - "body": "" + "body": "" }, "workflow-human-in-loop": { - "body": "", + "body": "", "width": 16, "height": 16 }, "workflow-if-else": { - "body": "" + "body": "" }, "workflow-input-field": { "body": "", @@ -1031,71 +1046,71 @@ "height": 16 }, "workflow-iteration": { - "body": "" + "body": "" }, "workflow-iteration-start": { - "body": "", + "body": "", "width": 12, "height": 12 }, "workflow-jinja": { - "body": "", + "body": "", "width": 24, "height": 12 }, "workflow-knowledge-base": { - "body": "" + "body": "" }, "workflow-knowledge-retrieval": { - "body": "", + "body": "", "width": 16, "height": 16 }, "workflow-list-filter": { - "body": "", + "body": "", "width": 16, "height": 16 }, "workflow-llm": { - "body": "" + "body": "" }, "workflow-loop": { - "body": "", + "body": "", "width": 18, "height": 16 }, "workflow-loop-end": { - "body": "", + "body": "", "width": 16, "height": 16 }, "workflow-parameter-extractor": { - "body": "" + "body": "" }, "workflow-question-classifier": { - "body": "" + "body": "" }, "workflow-schedule": { - "body": "", + "body": "", "width": 16, "height": 16 }, "workflow-templating-transform": { - "body": "" + "body": "" }, "workflow-trigger-all": { - "body": "" + "body": "" }, "workflow-variable-x": { - "body": "" + "body": "" }, "workflow-webhook-line": { - "body": "", + "body": "", "width": 16, "height": 16 }, "workflow-window-cursor": { - "body": "", + "body": "", "width": 16, "height": 16 } diff --git a/packages/iconify-collections/custom-vender/info.json b/packages/iconify-collections/custom-vender/info.json index ea5f666503..52df22b171 100644 --- a/packages/iconify-collections/custom-vender/info.json +++ b/packages/iconify-collections/custom-vender/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-vender", "name": "Dify Custom Vender", - "total": 278, + "total": 281, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/packages/iconify-collections/package.json b/packages/iconify-collections/package.json index 07c29f0a07..752b7ce437 100644 --- a/packages/iconify-collections/package.json +++ b/packages/iconify-collections/package.json @@ -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:" } } diff --git a/packages/iconify-collections/scripts/generate-collections.mjs b/packages/iconify-collections/scripts/generate-collections.ts similarity index 59% rename from packages/iconify-collections/scripts/generate-collections.mjs rename to packages/iconify-collections/scripts/generate-collections.ts index 1c734731e6..5cc67dd588 100644 --- a/packages/iconify-collections/scripts/generate-collections.mjs +++ b/packages/iconify-collections/scripts/generate-collections.ts @@ -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 & { + parent: string +} + +type ImportedCollection = { + icons?: Record + aliases?: Record + lastModified?: number +} + +type ImportedCollections = Record + +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 = {} + const aliases: Record = {} 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, + 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 aliases?: Record @@ -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, + name: string, + version: string, +): Promise => { 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 { + 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 +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65f2e05bf4..0c276fae9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3dd5b403a3..f02d05b233 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/web/AGENTS.md b/web/AGENTS.md index 4a705bf4b8..5e9f7ed11c 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -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) diff --git a/web/README.md b/web/README.md index 683a18c769..eb964b01e3 100644 --- a/web/README.md +++ b/web/README.md @@ -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 diff --git a/web/__tests__/app/app-access-control-flow.test.tsx b/web/__tests__/app/app-access-control-flow.test.tsx index 63f7fd0378..e1284bfc5b 100644 --- a/web/__tests__/app/app-access-control-flow.test.tsx +++ b/web/__tests__/app/app-access-control-flow.test.tsx @@ -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( - - {ui} - , - ) -} - 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) => unknown) => selector({ - systemFeatures: { - webapp_auth: { - enabled: true, - }, - }, - }), -})) - vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ formatTimeFromNow: (value: number) => `ago:${value}`, diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx index 9c09acf6a1..d4bf56e7e4 100644 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -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( - - {ui} - , - ) -} - 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) => unknown) => selector({ - systemFeatures: { - webapp_auth: { - enabled: true, - }, - }, - }), -})) - vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ formatTimeFromNow: (value: number) => `ago:${value}`, diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 8c3219794d..b0854072d2 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -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) => 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 => ({ const mockOnRefresh = vi.fn() const renderAppCard = (app?: Partial) => { - return render() + return renderWithSystemFeatures( + , + { systemFeatures: mockSystemFeatures }, + ) } const openOperationsMenu = () => { diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index e0f09ad2ac..f721777eab 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -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) => 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) => { - return renderWithNuqs( - , - { searchParams }, +const renderListUI = (ui: ReactElement, searchParams?: Record) => { + const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ + systemFeatures: mockSystemFeatures, + }) + const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + ) + return { ...render(ui, { wrapper: Wrapper }), onUrlUpdate } +} + +const renderList = (searchParams?: Record) => { + return renderListUI(, 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() + const { rerender } = renderListUI() 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() + const { rerender } = renderListUI() rerender() diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index ab45860a07..f63c3addb3 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -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) => 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() + const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ + systemFeatures: mockSystemFeatures, + }) + const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper() + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + return { ...render(, { wrapper: Wrapper }), onUrlUpdate } } describe('Create App Flow', () => { diff --git a/web/__tests__/base/chat-flow.test.tsx b/web/__tests__/base/chat-flow.test.tsx index 2a02c063fd..6ede7c766b 100644 --- a/web/__tests__/base/chat-flow.test.tsx +++ b/web/__tests__/base/chat-flow.test.tsx @@ -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' diff --git a/web/__tests__/embedded-user-id-store.test.tsx b/web/__tests__/embedded-user-id-store.test.tsx index 04597ccfeb..7e9eec644b 100644 --- a/web/__tests__/embedded-user-id-store.test.tsx +++ b/web/__tests__/embedded-user-id-store.test.tsx @@ -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( , @@ -125,7 +93,7 @@ describe('WebAppStoreProvider embedded user id handling', () => { })) mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({}) - render( + renderWithSystemFeatures( , diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx index e2c7831018..6af17119be 100644 --- a/web/__tests__/explore/explore-app-list-flow.test.tsx +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -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' diff --git a/web/__tests__/header/account-dropdown-flow.test.tsx b/web/__tests__/header/account-dropdown-flow.test.tsx index 6a645c7a43..b4a3befea0 100644 --- a/web/__tests__/header/account-dropdown-flow.test.tsx +++ b/web/__tests__/header/account-dropdown-flow.test.tsx @@ -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) => 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(, { + systemFeatures: { + branding: { + enabled: false, + workspace_logo: '', + }, }, }) - - return render( - - - , - ) } describe('Header Account Dropdown Flow', () => { diff --git a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx index 91e32155e7..3d08fe9d7c 100644 --- a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx +++ b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx @@ -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 = { diff --git a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx index 9202f647af..bd089d325c 100644 --- a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx +++ b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx @@ -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) => 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( - plugins view
} - marketplace={
marketplace view
} - />, - { 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 }) => ( + + {children} + ) + return { + ...render( + plugins view} + marketplace={
marketplace view
} + />, + { wrapper: Wrapper }, + ), + onUrlUpdate, + } } describe('Plugin Page Shell Flow', () => { diff --git a/web/__tests__/share/text-generation-index-flow.test.tsx b/web/__tests__/share/text-generation-index-flow.test.tsx index 2fec054a47..638f774c16 100644 --- a/web/__tests__/share/text-generation-index-flow.test.tsx +++ b/web/__tests__/share/text-generation-index-flow.test.tsx @@ -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() + renderWithSystemFeatures(, { 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() + renderWithSystemFeatures(, { systemFeatures: mockSystemFeatures }) await waitFor(() => { expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() diff --git a/web/__tests__/tools/provider-list-shell-flow.test.tsx b/web/__tests__/tools/provider-list-shell-flow.test.tsx index d0d096f072..afa3f45e9f 100644 --- a/web/__tests__/tools/provider-list-shell-flow.test.tsx +++ b/web/__tests__/tools/provider-list-shell-flow.test.tsx @@ -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) => 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(, { searchParams }) + const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ + systemFeatures: { enable_marketplace: true }, + }) + const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + return { ...render(, { wrapper: Wrapper }), onUrlUpdate } } describe('Tool Provider List Shell Flow', () => { diff --git a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx index aa8f59ca31..b1cc0c1312 100644 --- a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx +++ b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx @@ -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 }) => ( - {children} - ) + return wrapper } describe('Tool Browsing & Filtering Integration', () => { diff --git a/web/__tests__/utils/mock-system-features.tsx b/web/__tests__/utils/mock-system-features.tsx new file mode 100644 index 0000000000..6884e68237 --- /dev/null +++ b/web/__tests__/utils/mock-system-features.tsx @@ -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 extends Array + ? Array + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T + +const buildSystemFeatures = ( + overrides: DeepPartial = {}, +): SystemFeatures => { + const o = overrides as Partial + 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 => { + 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 | 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 }) => ( + {children} + ) + return { queryClient, systemFeatures, wrapper } +} + +export const renderWithSystemFeatures = ( + ui: ReactElement, + options: SystemFeaturesTestOptions & Omit = {}, +): 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 = ( + callback: (props: Props) => Result, + options: SystemFeaturesTestOptions & Omit, 'wrapper'> = {}, +): RenderHookResult & { 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 } +} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 7f8d6d535e..5c3a237aca 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -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 +} + const AppDetailLayout: FC = (props) => { const { children, @@ -80,14 +82,6 @@ const AppDetailLayout: FC = (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 = (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]) diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index c5719d6a61..179557b40e 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -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 +} + const DatasetDetailLayout: FC = (props) => { const { children, @@ -106,16 +108,16 @@ const DatasetDetailLayout: FC = (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, ] } diff --git a/web/app/(commonLayout)/error.tsx b/web/app/(commonLayout)/error.tsx new file mode 100644 index 0000000000..dbc5ded3e9 --- /dev/null +++ b/web/app/(commonLayout)/error.tsx @@ -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 + + return ( +
+
+ {t('errorBoundary.message')} +
+ +
+ ) +} diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index 35da7ef792..699d2a4348 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -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 ( <> - @@ -37,7 +34,6 @@ const Layout = ({ children }: { children: ReactNode }) => { - diff --git a/web/app/(commonLayout)/loading.tsx b/web/app/(commonLayout)/loading.tsx new file mode 100644 index 0000000000..3a5a14dc25 --- /dev/null +++ b/web/app/(commonLayout)/loading.tsx @@ -0,0 +1,9 @@ +import Loading from '@/app/components/base/loading' + +export default function CommonLayoutLoading() { + return ( +
+ +
+ ) +} diff --git a/web/app/(shareLayout)/webapp-reset-password/layout.tsx b/web/app/(shareLayout)/webapp-reset-password/layout.tsx index 1a035bfb44..82749c8641 100644 --- a/web/app/(shareLayout)/webapp-reset-password/layout.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/layout.tsx @@ -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 ( <>
diff --git a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx index fe6b157c1e..003aab4cab 100644 --- a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx @@ -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() diff --git a/web/app/(shareLayout)/webapp-signin/layout.tsx b/web/app/(shareLayout)/webapp-signin/layout.tsx index 5451b45194..99dd787cec 100644 --- a/web/app/(shareLayout)/webapp-signin/layout.tsx +++ b/web/app/(shareLayout)/webapp-signin/layout.tsx @@ -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 ( <> diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index 436c7e64bb..9b7b64eac5 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -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) diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index 4b94a7210f..a1e14ed815 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -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() diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index a26fa942db..09c083b60b 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -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) diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx index 0893b130c4..ccae182c9a 100644 --- a/web/app/account/(commonLayout)/avatar.tsx +++ b/web/app/account/(commonLayout)/avatar.tsx @@ -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() diff --git a/web/app/account/(commonLayout)/header.tsx b/web/app/account/(commonLayout)/header.tsx index f0912d45d5..37c1dcdd23 100644 --- a/web/app/account/(commonLayout)/header.tsx +++ b/web/app/account/(commonLayout)/header.tsx @@ -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') diff --git a/web/app/account/(commonLayout)/layout.tsx b/web/app/account/(commonLayout)/layout.tsx index 8fdbd8a238..f116cd00f9 100644 --- a/web/app/account/(commonLayout)/layout.tsx +++ b/web/app/account/(commonLayout)/layout.tsx @@ -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 ( <> - diff --git a/web/app/account/oauth/authorize/layout.tsx b/web/app/account/oauth/authorize/layout.tsx index 9460e7fc54..850fe9c2b5 100644 --- a/web/app/account/oauth/authorize/layout.tsx +++ b/web/app/account/oauth/authorize/layout.tsx @@ -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 (
diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index cd035ce16f..dd95dc04ba 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -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)}`) diff --git a/web/app/activate/page.tsx b/web/app/activate/page.tsx index e7ab413005..995227ec6e 100644 --- a/web/app/activate/page.tsx +++ b/web/app/activate/page.tsx @@ -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 (
diff --git a/web/app/components/__tests__/splash.spec.tsx b/web/app/components/__tests__/splash.spec.tsx deleted file mode 100644 index 296ef48cdb..0000000000 --- a/web/app/components/__tests__/splash.spec.tsx +++ /dev/null @@ -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 - -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) - - render() - - 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) - - render() - - 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) - - render() - - expect(screen.queryByRole('status')).not.toBeInTheDocument() - }) -}) diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx index 30d8f3e410..6788fc700b 100644 --- a/web/app/components/app-initializer.tsx +++ b/web/app/components/app-initializer.tsx @@ -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 : } diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx index aa05d933fe..72bf378835 100644 --- a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx @@ -126,7 +126,7 @@ const AppInfoDetailPanel = ({ secondaryOperations={secondaryOperations} />
- {appDetail.type !== AppTypeEnum.EVALUATION && ( + {appDetail.workflow_kind !== AppTypeEnum.EVALUATION && ( 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), diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index 2997d4b4cf..cff670e10f 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -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) diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index f476b0f7b5..f8da2dda9c 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -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, @@ -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 ? ( -
- embedded modal - -
- ) +
+ embedded modal + +
+ ) : 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( - , - ) - - 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( - , - ) - - 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( - , - ) - - 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( - , - ) - - 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( - , - ) - - 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( , ) @@ -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( + , + ) + + 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') + }) }) diff --git a/web/app/components/app/app-publisher/evaluation-workflow-switch-confirm-dialog.tsx b/web/app/components/app/app-publisher/evaluation-workflow-switch-confirm-dialog.tsx index 60efee3f93..3caa0684a8 100644 --- a/web/app/components/app/app-publisher/evaluation-workflow-switch-confirm-dialog.tsx +++ b/web/app/components/app/app-publisher/evaluation-workflow-switch-confirm-dialog.tsx @@ -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 = ({ - + {targetName} @@ -108,10 +108,10 @@ const EvaluationWorkflowSwitchConfirmDialog = ({
- + {t('common.switchToStandardWorkflowConfirm.title', { ns: 'workflow' })} - + {t('common.switchToStandardWorkflowConfirm.activeIn', { ns: 'workflow', count: targets.length })} @@ -123,7 +123,7 @@ const EvaluationWorkflowSwitchConfirmDialog = ({
- + {t('common.switchToStandardWorkflowConfirm.dependentWorkflows', { ns: 'workflow' })} diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 7e4ed89215..eee40257f2 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -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( () => (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 && ( - <> - { - handleOpenChange(false) - setShowAppAccessControl(true) - }} + { + !isEvaluationWorkflowType && ( + <> + { + handleOpenChange(false) + setShowAppAccessControl(true) + }} + /> + { + 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} + /> + + ) + } +
+ + setEmbeddingModalOpen(false)} + appBaseUrl={appBaseURL} + accessToken={accessToken} + hiddenInputs={hiddenLaunchVariables} /> - { - 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 && { setShowAppAccessControl(false) }} /> } + - - ) - } -
- - setEmbeddingModalOpen(false)} - appBaseUrl={appBaseURL} - accessToken={accessToken} - hiddenInputs={hiddenLaunchVariables} - /> -{ showAppAccessControl && { setShowAppAccessControl(false) }} /> } - - - void performWorkflowTypeSwitch()} - /> + + void performWorkflowTypeSwitch()} + /> ) } diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx index cac606f753..8ecd37d158 100644 --- a/web/app/components/app/app-publisher/sections.tsx +++ b/web/app/components/app/app-publisher/sections.tsx @@ -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 = ({
{publishedAt ? ( -
-
- {t('common.publishedAt', { ns: 'workflow' })} - {' '} - {formatTimeFromNow(publishedAt)} +
+
+ {t('common.publishedAt', { ns: 'workflow' })} + {' '} + {formatTimeFromNow(publishedAt)} +
+ {isChatApp && ( + + )}
- {isChatApp && ( - - )} -
- ) + ) : ( -
- {t('common.autoSaved', { ns: 'workflow' })} - {' '} - · - {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} -
- )} +
+ {t('common.autoSaved', { ns: 'workflow' })} + {' '} + · + {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} +
+ )} {debugWithMultipleModel ? ( - handlePublish(item)} - /> - ) + handlePublish(item)} + /> + ) : ( - <> - - {workflowTypeSwitchConfig && ( - - + {workflowTypeSwitchConfig && ( + + - - )} - {startNodeLimitExceeded && ( -
-

- {t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })} - {t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })} -

-

- {t('publishLimit.startNodeDesc', { ns: 'workflow' })} -

- -
- )} - - )} + + + { + e.preventDefault() + e.stopPropagation() + }} + > + + + )} + /> + + {t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })} + + + +
+ )} + {startNodeLimitExceeded && ( +
+

+ {t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })} + {t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })} +

+

+ {t('publishLimit.startNodeDesc', { ns: 'workflow' })} +

+ +
+ )} + + )}
) } @@ -350,10 +350,10 @@ export const PublisherActionsSection = ({ icon={} actionButton={showRunConfig ? { - ariaLabel: t('operation.config', { ns: 'common' }), - icon: , - onClick: () => handleOpenRunConfig?.(appURL), - } + ariaLabel: t('operation.config', { ns: 'common' }), + icon: , + onClick: () => handleOpenRunConfig?.(appURL), + } : undefined} > {t('common.runApp', { ns: 'workflow' })} @@ -361,34 +361,34 @@ export const PublisherActionsSection = ({ {appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION ? ( - - } - actionButton={showBatchRunConfig - ? { - ariaLabel: t('operation.config', { ns: 'common' }), - icon: , - onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`), - } - : undefined} - > - {t('common.batchRunApp', { ns: 'workflow' })} - - - ) + + } + actionButton={showBatchRunConfig + ? { + ariaLabel: t('operation.config', { ns: 'common' }), + icon: , + onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`), + } + : undefined} + > + {t('common.batchRunApp', { ns: 'workflow' })} + + + ) : ( - } - > - {t('common.embedIntoSite', { ns: 'workflow' })} - - )} + } + > + {t('common.embedIntoSite', { ns: 'workflow' })} + + )} { 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(() => { diff --git a/web/app/components/app/overview/__tests__/app-card.spec.tsx b/web/app/components/app/overview/__tests__/app-card.spec.tsx index 608097bcbc..f8f5743b98 100644 --- a/web/app/components/app/overview/__tests__/app-card.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card.spec.tsx @@ -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, diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index fc0132c45a..09495bb659 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -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>({}) 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, diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 0c80ee000e..6a71dbac52 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -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) => 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()), diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 02499385e7..932db440da 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -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 = {}, searchParams = '') => { - return renderWithNuqs(, { searchParams }) + const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({ + systemFeatures: { branding: { enabled: false } }, + }) + return renderWithNuqs(, { 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() + const { unmount } = renderList() expect(screen.getByText('app.types.all'))!.toBeInTheDocument() unmount() diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index d0bd9967c4..80aab3ce4d 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -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 = ({ type AppCardOperationsMenuContentProps = Omit const AppCardOperationsMenuContent: React.FC = (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 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() diff --git a/web/app/components/apps/app-type-filter.tsx b/web/app/components/apps/app-type-filter.tsx index 6e8e914f02..49c98f0084 100644 --- a/web/app/components/apps/app-type-filter.tsx +++ b/web/app/components/apps/app-type-filter.tsx @@ -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' diff --git a/web/app/components/apps/creators-filter.tsx b/web/app/components/apps/creators-filter.tsx index 59c3ea3343..aa4275a65a 100644 --- a/web/app/components/apps/creators-filter.tsx +++ b/web/app/components/apps/creators-filter.tsx @@ -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' diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 42490aec21..388f15815c 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -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 = ({ 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( diff --git a/web/app/components/base/amplitude/AmplitudeProvider.tsx b/web/app/components/base/amplitude/AmplitudeProvider.tsx index 9ba64ceb30..346cfaa7c4 100644 --- a/web/app/components/base/amplitude/AmplitudeProvider.tsx +++ b/web/app/components/base/amplitude/AmplitudeProvider.tsx @@ -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 = { - '': '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 = ({ 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 diff --git a/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx b/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx index 5835634eb7..a0080a0c0c 100644 --- a/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx +++ b/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx @@ -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() + + unmount() + render() + + 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() diff --git a/web/app/components/base/amplitude/__tests__/init.spec.ts b/web/app/components/base/amplitude/__tests__/init.spec.ts new file mode 100644 index 0000000000..25a5410148 --- /dev/null +++ b/web/app/components/base/amplitude/__tests__/init.spec.ts @@ -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() + }) + }) +}) diff --git a/web/app/components/base/amplitude/init.ts b/web/app/components/base/amplitude/init.ts new file mode 100644 index 0000000000..209b7dfac0 --- /dev/null +++ b/web/app/components/base/amplitude/init.ts @@ -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 = { + '': '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 +} diff --git a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx index af58a29fcc..4cbe4ce8d1 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx @@ -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' diff --git a/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx index e306569140..c9398ee927 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx @@ -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' diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx index 948700c2ce..170f6d7fb5 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx @@ -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 { - 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, diff --git a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx index 999f035301..74efb91e83 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx @@ -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(null) const [showRename, setShowRename] = useState(null) diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx index a87c206412..0cd22c97be 100644 --- a/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx @@ -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: () =>
chat area
, @@ -125,19 +127,9 @@ const createHookReturn = (overrides: Partial = {}): 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() diff --git a/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx index 50ba15ae61..3142bcd315 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx @@ -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: () =>
, })) -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(
) @@ -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(
) 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(
) expect(screen.getByAltText('Dify logo')).toBeInTheDocument() }) diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.tsx index aeec29a477..598e3068de 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/index.tsx @@ -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 = ({ 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 diff --git a/web/app/components/base/chat/embedded-chatbot/index.tsx b/web/app/components/base/chat/embedded-chatbot/index.tsx index 7c0f2feb7a..886549ca96 100644 --- a/web/app/components/base/chat/embedded-chatbot/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/index.tsx @@ -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 diff --git a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx index 77f9a0253b..4947d2c7c0 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx @@ -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() - }) - }) }) diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx index 4d117c7085..d8cdc56849 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx @@ -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( { 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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 () => { diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx index 5a579519f0..b85367ba93 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx @@ -102,7 +102,7 @@ const ConversationOpener = ({ <> {!isHovering && (
- {opening.opening_statement || t('openingStatement.placeholder', { ns: 'appDebug' })} + {opening.opening_statement || t('openingStatement.placeholderLine1', { ns: 'appDebug' })}
)} {isHovering && ( diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx index 2b0e809688..f69b507115 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx @@ -3,8 +3,9 @@ import type { InputVar } from '@/app/components/workflow/types' import type { PromptVariable } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useBoolean } from 'ahooks' -import { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -13,7 +14,6 @@ import { ReactSortable } from 'react-sortablejs' import ConfirmAddVar from '@/app/components/app/configuration/config-prompt/confirm-add-var' import { getInputKeys } from '@/app/components/base/block-input' import Divider from '@/app/components/base/divider' -import Modal from '@/app/components/base/modal' import PromptEditor from '@/app/components/base/prompt-editor' import { checkKeys, getNewVar } from '@/utils/var' @@ -39,6 +39,7 @@ const OpeningSettingModal = ({ const { t } = useTranslation() const [tempValue, setTempValue] = useState(data?.opening_statement || '') useEffect(() => { + // eslint-disable-next-line react/set-state-in-effect setTempValue(data.opening_statement || '') }, [data.opening_statement]) const [tempSuggestedQuestions, setTempSuggestedQuestions] = useState(data.suggested_questions || []) @@ -99,22 +100,49 @@ const OpeningSettingModal = ({ const [focusID, setFocusID] = useState(null) const [deletingID, setDeletingID] = useState(null) + const [autoFocusQuestionID, setAutoFocusQuestionID] = useState(null) + const openerPlaceholder = ( + + {t('openingStatement.placeholderLine1', { ns: 'appDebug' })} +
+ {t('openingStatement.placeholderLine2', { ns: 'appDebug' })} +
+ ) const renderQuestions = () => { return (
-
-
-
{t('openingStatement.openingQuestion', { ns: 'appDebug' })}
-
·
-
- {tempSuggestedQuestions.length} - / - {MAX_QUESTION_NUM} +
+
+
+ {t('openingStatement.openingQuestion', { ns: 'appDebug' })}
+ + + + + )} + /> + + {t('openingStatement.openingQuestionDescription', { ns: 'appDebug' })} + + +
+
+ {tempSuggestedQuestions.length} + / + {MAX_QUESTION_NUM}
-
+ { @@ -133,8 +161,8 @@ const OpeningSettingModal = ({
@@ -152,8 +180,13 @@ const OpeningSettingModal = ({ return item })) }} + autoFocus={autoFocusQuestionID === index} className="h-9 w-full grow cursor-pointer overflow-x-auto rounded-lg border-0 bg-transparent pr-8 pl-1.5 text-sm leading-9 text-text-secondary focus:outline-hidden" - onFocus={() => setFocusID(index)} + onFocus={() => { + setFocusID(index) + if (autoFocusQuestionID === index) + setAutoFocusQuestionID(null) + }} onBlur={() => setFocusID(null)} /> @@ -173,7 +206,12 @@ const OpeningSettingModal = ({ {tempSuggestedQuestions.length < MAX_QUESTION_NUM && (
{ setTempSuggestedQuestions([...tempSuggestedQuestions, '']) }} + onClick={() => { + const nextIndex = tempSuggestedQuestions.length + setDeletingID(null) + setAutoFocusQuestionID(nextIndex) + setTempSuggestedQuestions([...tempSuggestedQuestions, '']) + }} className="mt-1 flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-components-button-tertiary-bg px-3 text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover" > @@ -185,81 +223,90 @@ const OpeningSettingModal = ({ } return ( - -
-
{t('feature.conversationOpener.title', { ns: 'appDebug' })}
-
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onCancel() - } - }} - > - -
-
-
-
- -
-
- ({ - name: item.name || item.key, - value: item.key, - })), - // Workflow variables - ...workflowVariables.map(item => ({ - name: item.variable, - value: item.variable, - })), - ], + !open && onCancel()} disablePointerDismissal> + +
+
{t('feature.conversationOpener.title', { ns: 'appDebug' })}
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onCancel() + } }} - /> - {renderQuestions()} + > + +
-
-
- - -
- {isShowConfirmAddVar && ( - - )} - +
+
+
+ {t('openingStatement.editorTitle', { ns: 'appDebug' })} +
+
+ ({ + name: item.name || item.key, + value: item.key, + })), + // Workflow variables + ...workflowVariables.map(item => ({ + name: item.variable, + value: item.variable, + })), + ], + }} + /> +
+
+
+ {renderQuestions()} +
+
+
+ + +
+ {isShowConfirmAddVar && ( + + )} + + ) } diff --git a/web/app/components/base/features/new-feature-panel/index.tsx b/web/app/components/base/features/new-feature-panel/index.tsx index 8425e03ae1..84c7715577 100644 --- a/web/app/components/base/features/new-feature-panel/index.tsx +++ b/web/app/components/base/features/new-feature-panel/index.tsx @@ -1,7 +1,7 @@ import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { InputVar } from '@/app/components/workflow/types' import type { PromptVariable } from '@/models/debug' -import { RiCloseLine, RiInformation2Fill } from '@remixicon/react' +import { RiCloseLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply' @@ -64,19 +64,6 @@ const NewFeaturePanel = ({
{/* list */}
- {showFileUpload && ( -
-
-
-
- -
-
- {isChatMode ? t('common.fileUploadTip', { ns: 'workflow' }) : t('common.ImageUploadLegacyTip', { ns: 'workflow' })} -
-
-
- )} {!isChatMode && !inWorkflow && ( )} diff --git a/web/app/components/base/portal-to-follow-elem/index.tsx b/web/app/components/base/portal-to-follow-elem/index.tsx index 48099c7020..8b531be309 100644 --- a/web/app/components/base/portal-to-follow-elem/index.tsx +++ b/web/app/components/base/portal-to-follow-elem/index.tsx @@ -1,6 +1,6 @@ 'use client' /** - * @deprecated Use semantic overlay primitives from `@/app/components/base/ui/` instead. + * @deprecated Use semantic overlay primitives from `@langgenius/dify-ui/*` instead. * This component will be removed after migration is complete. * See: https://github.com/langgenius/dify/issues/32767 * diff --git a/web/app/components/custom/custom-page/__tests__/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx index d6cc15ed2b..1f3655a9f8 100644 --- a/web/app/components/custom/custom-page/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -1,9 +1,10 @@ +import type { ReactElement } from 'react' import type { AppContextValue } from '@/context/app-context' -import type { SystemFeatures } from '@/types/feature' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' import { @@ -12,12 +13,19 @@ import { useAppContext, userProfilePlaceholder, } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' -import { defaultSystemFeatures } from '@/types/feature' import CustomPage from '../index' +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { + branding: { + enabled: true, + workspace_logo: 'https://example.com/workspace-logo.png', + }, + }, +}) + const { mockToast } = vi.hoisted(() => { const mockToast = Object.assign(vi.fn(), { success: vi.fn(), @@ -44,9 +52,6 @@ vi.mock('@/context/app-context', async (importOriginal) => { useAppContext: vi.fn(), } }) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(), -})) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, })) @@ -54,7 +59,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ const mockUseProviderContext = vi.mocked(useProviderContext) const mockUseModalContext = vi.mocked(useModalContext) const mockUseAppContext = vi.mocked(useAppContext) -const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) const createProviderContext = ({ enableBilling = false, @@ -93,15 +97,6 @@ const createAppContextValue = (): AppContextValue => ({ isValidatingCurrentWorkspace: false, }) -const createSystemFeatures = (): SystemFeatures => ({ - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: true, - workspace_logo: 'https://example.com/workspace-logo.png', - }, -}) - describe('CustomPage', () => { const setShowPricingModal = vi.fn() @@ -113,10 +108,6 @@ describe('CustomPage', () => { setShowPricingModal, } as unknown as ReturnType) mockUseAppContext.mockReturnValue(createAppContextValue()) - mockUseGlobalPublicStore.mockImplementation(selector => selector({ - systemFeatures: createSystemFeatures(), - setSystemFeatures: vi.fn(), - })) }) // Integration coverage for the page and its child custom brand section. diff --git a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx index 3ca7c34b84..99cbc03b32 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx @@ -1,9 +1,10 @@ import type { ChangeEvent } from 'react' import type { AppContextValue } from '@/context/app-context' import type { SystemFeatures } from '@/types/feature' -import { act, renderHook } from '@testing-library/react' +import { act } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { renderHookWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' import { defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' @@ -13,12 +14,22 @@ import { useAppContext, userProfilePlaceholder, } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { updateCurrentWorkspace } from '@/service/common' -import { defaultSystemFeatures } from '@/types/feature' import useWebAppBrand from '../use-web-app-brand' +let currentBrandingOverrides: Partial = {} +const renderHook = (callback: (props: Props) => Result) => + renderHookWithSystemFeatures(callback, { + systemFeatures: { + branding: { + enabled: true, + workspace_logo: 'https://example.com/workspace-logo.png', + ...currentBrandingOverrides, + }, + }, + }) + const { mockNotify, mockToast } = vi.hoisted(() => { const mockNotify = vi.fn() const mockToast = Object.assign(mockNotify, { @@ -49,9 +60,6 @@ vi.mock('@/context/app-context', async (importOriginal) => { vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(), -})) vi.mock('@/app/components/base/image-uploader/utils', () => ({ imageUpload: vi.fn(), getImageUploadErrorMessage: vi.fn(), @@ -60,7 +68,6 @@ vi.mock('@/app/components/base/image-uploader/utils', () => ({ const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace) const mockUseAppContext = vi.mocked(useAppContext) const mockUseProviderContext = vi.mocked(useProviderContext) -const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) const mockImageUpload = vi.mocked(imageUpload) const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage) @@ -80,16 +87,6 @@ const createProviderContext = ({ }) } -const createSystemFeatures = (brandingOverrides: Partial = {}): SystemFeatures => ({ - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: true, - workspace_logo: 'https://example.com/workspace-logo.png', - ...brandingOverrides, - }, -}) - const createAppContextValue = (overrides: Partial = {}): AppContextValue => { const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides const workspaceOverrides: Partial = currentWorkspaceOverride ?? {} @@ -122,21 +119,16 @@ const createAppContextValue = (overrides: Partial = {}): AppCon describe('useWebAppBrand', () => { let appContextValue: AppContextValue - let systemFeatures: SystemFeatures beforeEach(() => { vi.clearAllMocks() appContextValue = createAppContextValue() - systemFeatures = createSystemFeatures() + currentBrandingOverrides = {} mockUpdateCurrentWorkspace.mockResolvedValue(appContextValue.currentWorkspace) mockUseAppContext.mockImplementation(() => appContextValue) mockUseProviderContext.mockReturnValue(createProviderContext()) - mockUseGlobalPublicStore.mockImplementation(selector => selector({ - systemFeatures, - setSystemFeatures: vi.fn(), - })) mockGetImageUploadErrorMessage.mockReturnValue('upload error') }) @@ -174,10 +166,7 @@ describe('useWebAppBrand', () => { }) it('should fall back to an empty workspace logo when branding is disabled', () => { - systemFeatures = createSystemFeatures({ - enabled: false, - workspace_logo: '', - }) + currentBrandingOverrides = { enabled: false, workspace_logo: '' } const { result } = renderHook(() => useWebAppBrand()) diff --git a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts index 145e7ee806..e24edab421 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts +++ b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts @@ -1,13 +1,14 @@ import type { ChangeEvent } from 'react' import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' import { Plan } from '@/app/components/billing/type' import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { updateCurrentWorkspace } from '@/service/common' +import { systemFeaturesQueryOptions } from '@/service/system-features' const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024 const CUSTOM_CONFIG_URL = '/workspaces/custom-config' @@ -19,7 +20,7 @@ const useWebAppBrand = () => { const [fileId, setFileId] = useState('') const [imgKey, setImgKey] = useState(() => Date.now()) const [uploadProgress, setUploadProgress] = useState(0) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isSandbox = enableBilling && plan.type === Plan.sandbox const uploading = uploadProgress > 0 && uploadProgress < 100 const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || '' diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx index f40c87b7ac..a34952cde3 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx @@ -1,8 +1,14 @@ -import { render, screen } from '@testing-library/react' +import type { ReactElement } from 'react' +import { screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import BuiltInPipelineList from '../built-in-pipeline-list' +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { enable_marketplace: true }, +}) + vi.mock('../create-card', () => ({ default: () =>
CreateCard
, })) @@ -22,13 +28,6 @@ vi.mock('@/context/i18n', () => ({ useLocale: () => mockLocale, })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn((selector) => { - const state = { systemFeatures: { enable_marketplace: true } } - return selector(state) - }), -})) - const mockUsePipelineTemplateList = vi.fn() vi.mock('@/service/use-pipeline', () => ({ usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args), diff --git a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx index 31c62758c1..3d14dd2f95 100644 --- a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx @@ -1,7 +1,8 @@ +import { useSuspenseQuery } from '@tanstack/react-query' import { useMemo } from 'react' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { usePipelineTemplateList } from '@/service/use-pipeline' import CreateCard from './create-card' import TemplateCard from './template-card' @@ -13,7 +14,10 @@ const BuiltInPipelineList = () => { return locale return LanguagesSupported[0] }, [locale]) - const enableMarketplace = useGlobalPublicStore(s => s.systemFeatures.enable_marketplace) + const { data: enableMarketplace } = useSuspenseQuery({ + ...systemFeaturesQueryOptions(), + select: s => s.enable_marketplace, + }) const { data: pipelineList, isLoading } = usePipelineTemplateList({ type: 'built-in', language }, enableMarketplace) const list = pipelineList?.pipeline_templates || [] diff --git a/web/app/components/datasets/list/__tests__/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx index 37a787ff51..beee35b06d 100644 --- a/web/app/components/datasets/list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/__tests__/index.spec.tsx @@ -1,7 +1,14 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactElement } from '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 List from '../index' +let mockBrandingEnabled = false +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { branding: { enabled: mockBrandingEnabled } }, +}) + const mockPush = vi.fn() const mockReplace = vi.fn() vi.mock('@/next/navigation', () => ({ @@ -20,15 +27,6 @@ vi.mock('@/context/app-context', () => ({ useSelector: () => true, })) -// Mock global public context -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ - systemFeatures: { - branding: { enabled: false }, - }, - }), -})) - // Mock external api panel context const mockSetShowExternalApiPanel = vi.fn() vi.mock('@/context/external-api-panel-context', () => ({ @@ -133,6 +131,7 @@ vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () describe('List', () => { beforeEach(() => { vi.clearAllMocks() + mockBrandingEnabled = false }) describe('Rendering', () => { @@ -319,18 +318,9 @@ describe('List', () => { }) it('should not show DatasetFooter when branding is enabled', async () => { - vi.doMock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ - systemFeatures: { - branding: { enabled: true }, - }, - }), - })) + mockBrandingEnabled = true - vi.resetModules() - const { default: ListComponent } = await import('../index') - - render() + render() expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument() }) diff --git a/web/app/components/datasets/list/index.tsx b/web/app/components/datasets/list/index.tsx index 34be78ab3f..1f7eba63c6 100644 --- a/web/app/components/datasets/list/index.tsx +++ b/web/app/components/datasets/list/index.tsx @@ -1,10 +1,11 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' +import { useSuspenseQuery } from '@tanstack/react-query' import { useBoolean, useDebounceFn } from 'ahooks' + // Libraries import { useState } from 'react' - import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import TagManagementModal from '@/app/components/base/tag-management' @@ -14,9 +15,9 @@ import { useStore as useTagStore } from '@/app/components/base/tag-management/st import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label' import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context' import { useExternalApiPanel } from '@/context/external-api-panel-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset' +import { systemFeaturesQueryOptions } from '@/service/system-features' // Components import ExternalAPIPanel from '../external-api/external-api-panel' import ServiceApi from '../extra-info/service-api' @@ -25,7 +26,7 @@ import Datasets from './datasets' const List = () => { const { t } = useTranslation() - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceOwner } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel() diff --git a/web/app/components/devtools/react-scan/loader.tsx b/web/app/components/devtools/react-scan/loader.tsx index bd310f292f..8e933c2b24 100644 --- a/web/app/components/devtools/react-scan/loader.tsx +++ b/web/app/components/devtools/react-scan/loader.tsx @@ -9,7 +9,7 @@ export function ReactScanLoader() {