Merge branch 'main' into jzh

This commit is contained in:
JzoNg 2026-04-20 12:06:18 +08:00
commit c44ba62da3
220 changed files with 2027 additions and 2096 deletions

View File

@ -129,6 +129,7 @@ class AppNamePayload(BaseModel):
class AppIconPayload(BaseModel): class AppIconPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon data") 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") icon_background: str | None = Field(default=None, description="Icon background color")
@ -729,7 +730,12 @@ class AppIconApi(Resource):
args = AppIconPayload.model_validate(console_ns.payload or {}) args = AppIconPayload.model_validate(console_ns.payload or {})
app_service = AppService() 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) response_model = AppDetail.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json") return response_model.model_dump(mode="json")

View File

@ -50,6 +50,7 @@ from fields.dataset_fields import (
from fields.document_fields import document_status_fields from fields.document_fields import document_status_fields
from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.entities.model_entities import ModelType
from libs.login import current_account_with_tenant, login_required 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, UploadFile from models import ApiToken, Dataset, Document, DocumentSegment, UploadFile
from models.dataset import DatasetPermission, DatasetPermissionEnum from models.dataset import DatasetPermission, DatasetPermissionEnum
from models.enums import ApiTokenType, SegmentStatus from models.enums import ApiTokenType, SegmentStatus
@ -889,7 +890,8 @@ class DatasetApiBaseUrlApi(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
def get(self): 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") @console_ns.route("/datasets/retrieval-setting")

View File

@ -1131,6 +1131,14 @@ class ToolMCPAuthApi(Resource):
with sessionmaker(db.engine).begin() as session: with sessionmaker(db.engine).begin() as session:
service = MCPToolManageService(session=session) service = MCPToolManageService(session=session)
service.clear_provider_credentials(provider_id=provider_id, tenant_id=tenant_id) 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 raise ValueError(f"Failed to connect to MCP server: {e}") from e

View File

@ -303,9 +303,16 @@ class StreamableHTTPTransport:
if response.status_code == 404: if response.status_code == 404:
if isinstance(message.root, JSONRPCRequest): 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( self._send_session_terminated_error(
ctx.server_to_client_queue, ctx.server_to_client_queue,
message.root.id, message.root.id,
message=error_msg,
) )
return return
@ -381,12 +388,13 @@ class StreamableHTTPTransport:
self, self,
server_to_client_queue: ServerToClientQueue, server_to_client_queue: ServerToClientQueue,
request_id: RequestId, request_id: RequestId,
message: str = "Session terminated by server",
): ):
"""Send a session terminated error response.""" """Send a session terminated error response."""
jsonrpc_error = JSONRPCError( jsonrpc_error = JSONRPCError(
jsonrpc="2.0", jsonrpc="2.0",
id=request_id, id=request_id,
error=ErrorData(code=32600, message="Session terminated by server"), error=ErrorData(code=32600, message=message),
) )
session_message = SessionMessage(JSONRPCMessage(jsonrpc_error)) session_message = SessionMessage(JSONRPCMessage(jsonrpc_error))
server_to_client_queue.put(session_message) server_to_client_queue.put(session_message)

View File

@ -47,23 +47,17 @@ def _cookie_domain() -> str | None:
def _real_cookie_name(cookie_name: str) -> str: def _real_cookie_name(cookie_name: str) -> str:
if is_secure() and _cookie_domain() is None: if is_secure() and _cookie_domain() is None:
return "__Host-" + cookie_name return "__Host-" + cookie_name
else: return cookie_name
return cookie_name
def _try_extract_from_header(request: Request) -> str | None: def _try_extract_from_header(request: Request) -> str | None:
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if auth_header: if not auth_header or " " not in auth_header:
if " " not in auth_header: return None
return None auth_scheme, auth_token = auth_header.split(None, 1)
else: if auth_scheme.lower() != "bearer":
auth_scheme, auth_token = auth_header.split(None, 1) return None
auth_scheme = auth_scheme.lower() return auth_token
if auth_scheme != "bearer":
return None
else:
return auth_token
return None
def extract_refresh_token(request: Request) -> str | None: 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 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)) or request.headers.get(
return request.cookies.get(_real_cookie_name(COOKIE_NAME_PASSPORT + "-" + app_code)) HEADER_NAME_PASSPORT
)
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
def set_access_token_to_cookie(request: Request, response: Response, token: str, samesite: str = "Lax"): 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: if not csrf_token:
_unauthorized() _unauthorized()
verified = {}
try: try:
verified = PassportService().verify(csrf_token) verified = PassportService().verify(csrf_token)
except: except Exception:
_unauthorized() _unauthorized()
raise # unreachable, but helps the type checker see verified is always bound
if verified.get("sub") != user_id: if verified.get("sub") != user_id:
_unauthorized() _unauthorized()
exp: int | None = verified.get("exp") exp: int | None = verified.get("exp")
if not exp: if not exp or exp < int(datetime.now(UTC).timestamp()):
_unauthorized() _unauthorized()
else:
time_now = int(datetime.now().timestamp())
if exp < time_now:
_unauthorized()
def generate_csrf_token(user_id: str) -> str: def generate_csrf_token(user_id: str) -> str:

3
api/libs/url_utils.py Normal file
View File

@ -0,0 +1,3 @@
def normalize_api_base_url(base_url: str) -> str:
"""Normalize a base URL to always end with /v1, avoiding double /v1 suffixes."""
return base_url.rstrip("/").removesuffix("/v1").rstrip("/") + "/v1"

View File

@ -25,6 +25,7 @@ from graphon.enums import WorkflowExecutionStatus
from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType
from graphon.file import helpers as file_helpers from graphon.file import helpers as file_helpers
from libs.helper import generate_string # type: ignore[import-not-found] 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 libs.uuid_utils import uuidv7
from models.utils.file_input_compat import build_file_from_input_mapping from models.utils.file_input_compat import build_file_from_input_mapping
@ -446,7 +447,8 @@ class App(Base):
@property @property
def api_base_url(self) -> str: 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 @property
def tenant(self) -> Tenant | None: def tenant(self) -> Tenant | None:

View File

@ -303,17 +303,22 @@ class AppService:
return app 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 Update app icon
:param app: App instance :param app: App instance
:param icon: new icon :param icon: new icon
:param icon_background: new icon_background :param icon_background: new icon_background
:param icon_type: new icon type
:return: App instance :return: App instance
""" """
assert current_user is not None assert current_user is not None
app.icon = icon app.icon = icon
app.icon_background = icon_background 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_by = current_user.id
app.updated_at = naive_utc_now() app.updated_at = naive_utc_now()
db.session.commit() db.session.commit()

View File

@ -234,6 +234,35 @@ class TestAppEndpoints:
} }
) )
def test_app_icon_post_should_forward_icon_type(self, app, monkeypatch):
api = app_module.AppIconApi()
method = _unwrap(api.post)
payload = {
"icon": "https://example.com/icon.png",
"icon_type": "image",
"icon_background": "#FFFFFF",
}
app_service = MagicMock()
app_service.update_app_icon.return_value = SimpleNamespace()
response_model = MagicMock()
response_model.model_dump.return_value = {"id": "app-1"}
monkeypatch.setattr(app_module, "AppService", lambda: app_service)
monkeypatch.setattr(app_module.AppDetail, "model_validate", MagicMock(return_value=response_model))
with (
app.test_request_context("/console/api/apps/app-1/icon", method="POST", json=payload),
patch.object(type(console_ns), "payload", payload),
):
response = method(app_model=SimpleNamespace())
assert response == {"id": "app-1"}
assert app_service.update_app_icon.call_args.args[1:] == (
payload["icon"],
payload["icon_background"],
app_module.IconType.IMAGE,
)
class TestOpsTraceEndpoints: class TestOpsTraceEndpoints:
@pytest.fixture @pytest.fixture

View File

@ -658,15 +658,17 @@ class TestAppService:
# Update app icon # Update app icon
new_icon = "🌟" new_icon = "🌟"
new_icon_background = "#FFD93D" new_icon_background = "#FFD93D"
new_icon_type = "image"
mock_current_user = create_autospec(Account, instance=True) mock_current_user = create_autospec(Account, instance=True)
mock_current_user.id = account.id mock_current_user.id = account.id
mock_current_user.current_tenant_id = account.current_tenant_id mock_current_user.current_tenant_id = account.current_tenant_id
with patch("services.app_service.current_user", mock_current_user): 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 == new_icon
assert updated_app.icon_background == new_icon_background 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 assert updated_app.updated_by == account.id
# Verify other fields remain unchanged # Verify other fields remain unchanged

View File

@ -1772,6 +1772,21 @@ class TestDatasetApiBaseUrlApi:
assert response["api_base_url"] == "http://localhost:5000/v1" 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: class TestDatasetRetrievalSettingApi:
def test_get_success(self, app): def test_get_success(self, app):

View File

@ -971,6 +971,23 @@ class TestHandlePostRequestNew:
assert isinstance(item, SessionMessage) assert isinstance(item, SessionMessage)
assert isinstance(item.message.root, JSONRPCError) assert isinstance(item.message.root, JSONRPCError)
assert item.message.root.id == 77 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): def test_404_for_notification_no_error_sent(self):
t = _new_transport() t = _new_transport()

View File

@ -59,7 +59,7 @@
}, },
"web/__tests__/embedded-user-id-store.test.tsx": { "web/__tests__/embedded-user-id-store.test.tsx": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 3 "count": 1
} }
}, },
"web/__tests__/goto-anything/command-selector.test.tsx": { "web/__tests__/goto-anything/command-selector.test.tsx": {
@ -1182,14 +1182,6 @@
"count": 2 "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": { "web/app/components/base/features/new-feature-panel/feature-bar.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
@ -6407,11 +6399,6 @@
"count": 1 "count": 1
} }
}, },
"web/context/global-public-context.tsx": {
"react-refresh/only-export-components": {
"count": 3
}
},
"web/context/hooks/use-trigger-events-limit-modal.ts": { "web/context/hooks/use-trigger-events-limit-modal.ts": {
"react/set-state-in-effect": { "react/set-state-in-effect": {
"count": 3 "count": 3

View File

@ -1,6 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import AppPublisher from '@/app/components/app/app-publisher' import AppPublisher from '@/app/components/app/app-publisher'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
@ -24,27 +24,15 @@ let mockAppDetail: {
} }
} | null = null } | null = null
const createTestQueryClient = () => const renderWithQueryClient = (ui: React.ReactElement) =>
new QueryClient({ renderWithSystemFeatures(ui, {
defaultOptions: { systemFeatures: {
queries: { webapp_auth: {
retry: false, enabled: true,
},
mutations: {
retry: false,
}, },
}, },
}) })
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = createTestQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
vi.mock('react-i18next', () => ({ vi.mock('react-i18next', () => ({
useTranslation: () => ({ useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
@ -58,16 +46,6 @@ vi.mock('@/app/components/app/store', () => ({
}), }),
})) }))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
systemFeatures: {
webapp_auth: {
enabled: true,
},
},
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({ vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({ useFormatTimeFromNow: () => ({
formatTimeFromNow: (value: number) => `ago:${value}`, formatTimeFromNow: (value: number) => `ago:${value}`,

View File

@ -1,6 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import AppPublisher from '@/app/components/app/app-publisher' import AppPublisher from '@/app/components/app/app-publisher'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
@ -28,27 +28,15 @@ let mockAppDetail: {
} }
} | null = null } | null = null
const createTestQueryClient = () => const renderWithQueryClient = (ui: React.ReactElement) =>
new QueryClient({ renderWithSystemFeatures(ui, {
defaultOptions: { systemFeatures: {
queries: { webapp_auth: {
retry: false, enabled: true,
},
mutations: {
retry: false,
}, },
}, },
}) })
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = createTestQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
vi.mock('react-i18next', () => ({ vi.mock('react-i18next', () => ({
useTranslation: () => ({ useTranslation: () => ({
t: (key: string) => key, t: (key: string) => key,
@ -66,16 +54,6 @@ vi.mock('@/app/components/app/store', () => ({
}), }),
})) }))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
systemFeatures: {
webapp_auth: {
enabled: true,
},
},
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({ vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({ useFormatTimeFromNow: () => ({
formatTimeFromNow: (value: number) => `ago:${value}`, formatTimeFromNow: (value: number) => `ago:${value}`,

View File

@ -10,8 +10,9 @@
* - Access mode icons * - Access mode icons
*/ */
import type { App } from '@/types/app' 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import AppCard from '@/app/components/apps/app-card' import AppCard from '@/app/components/apps/app-card'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import { exportAppConfig, updateAppInfo } from '@/service/apps' import { exportAppConfig, updateAppInfo } from '@/service/apps'
@ -96,15 +97,6 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
const state = { systemFeatures: mockSystemFeatures }
if (typeof selector === 'function')
return selector(state)
return mockSystemFeatures
},
}))
vi.mock('@/context/provider-context', () => ({ vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged, onPlanInfoChanged: mockOnPlanInfoChanged,
@ -255,7 +247,10 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
const mockOnRefresh = vi.fn() const mockOnRefresh = vi.fn()
const renderAppCard = (app?: Partial<App>) => { const renderAppCard = (app?: Partial<App>) => {
return render(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />) return renderWithSystemFeatures(
<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />,
{ systemFeatures: mockSystemFeatures },
)
} }
const openOperationsMenu = () => { const openOperationsMenu = () => {

View File

@ -1,3 +1,4 @@
import type { ReactElement, ReactNode } from 'react'
/** /**
* Integration test: App List Browsing Flow * Integration test: App List Browsing Flow
* *
@ -8,11 +9,12 @@
*/ */
import type { AppListResponse } from '@/models/app' import type { AppListResponse } from '@/models/app'
import type { App } from '@/types/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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import List from '@/app/components/apps/list' import List from '@/app/components/apps/list'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import { renderWithNuqs } from '@/test/nuqs-testing' import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
let mockIsCurrentWorkspaceEditor = true let mockIsCurrentWorkspaceEditor = true
@ -64,13 +66,6 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
const state = { systemFeatures: mockSystemFeatures }
return selector ? selector(state) : state
},
}))
vi.mock('@/context/provider-context', () => ({ vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ useProviderContext: () => ({
onPlanInfoChanged: vi.fn(), onPlanInfoChanged: vi.fn(),
@ -197,11 +192,21 @@ const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse =>
total: apps.length, total: apps.length,
}) })
const renderList = (searchParams?: Record<string, string>) => { const renderListUI = (ui: ReactElement, searchParams?: Record<string, string>) => {
return renderWithNuqs( const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
<List controlRefreshList={0} />, systemFeatures: mockSystemFeatures,
{ searchParams }, })
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
const Wrapper = ({ children }: { children: ReactNode }) => (
<NuqsWrapper>
<SysWrapper>{children}</SysWrapper>
</NuqsWrapper>
) )
return { ...render(ui, { wrapper: Wrapper }), onUrlUpdate }
}
const renderList = (searchParams?: Record<string, string>) => {
return renderListUI(<List controlRefreshList={0} />, searchParams)
} }
describe('App List Browsing Flow', () => { describe('App List Browsing Flow', () => {
@ -245,7 +250,7 @@ describe('App List Browsing Flow', () => {
it('should transition from loading to content when data loads', () => { it('should transition from loading to content when data loads', () => {
mockIsLoading = true mockIsLoading = true
const { rerender } = renderWithNuqs(<List controlRefreshList={0} />) const { rerender } = renderListUI(<List controlRefreshList={0} />)
const skeletonCards = document.querySelectorAll('.animate-pulse') const skeletonCards = document.querySelectorAll('.animate-pulse')
expect(skeletonCards.length).toBeGreaterThan(0) expect(skeletonCards.length).toBeGreaterThan(0)
@ -445,7 +450,7 @@ describe('App List Browsing Flow', () => {
it('should call refetch when controlRefreshList increments', () => { it('should call refetch when controlRefreshList increments', () => {
mockPages = [createPage([createMockApp()])] mockPages = [createPage([createMockApp()])]
const { rerender } = renderWithNuqs(<List controlRefreshList={0} />) const { rerender } = renderListUI(<List controlRefreshList={0} />)
rerender(<List controlRefreshList={1} />) rerender(<List controlRefreshList={1} />)

View File

@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
/** /**
* Integration test: Create App Flow * Integration test: Create App Flow
* *
@ -9,11 +10,12 @@
*/ */
import type { AppListResponse } from '@/models/app' import type { AppListResponse } from '@/models/app'
import type { App } from '@/types/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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import List from '@/app/components/apps/list' import List from '@/app/components/apps/list'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import { renderWithNuqs } from '@/test/nuqs-testing' import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
let mockIsCurrentWorkspaceEditor = true let mockIsCurrentWorkspaceEditor = true
@ -51,13 +53,6 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
const state = { systemFeatures: mockSystemFeatures }
return selector ? selector(state) : state
},
}))
vi.mock('@/context/provider-context', () => ({ vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged, onPlanInfoChanged: mockOnPlanInfoChanged,
@ -251,7 +246,16 @@ const createPage = (apps: App[]): AppListResponse => ({
}) })
const renderList = () => { const renderList = () => {
return renderWithNuqs(<List controlRefreshList={0} />) const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
systemFeatures: mockSystemFeatures,
})
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper()
const Wrapper = ({ children }: { children: ReactNode }) => (
<NuqsWrapper>
<SysWrapper>{children}</SysWrapper>
</NuqsWrapper>
)
return { ...render(<List controlRefreshList={0} />, { wrapper: Wrapper }), onUrlUpdate }
} }
describe('Create App Flow', () => { describe('Create App Flow', () => {

View File

@ -1,8 +1,9 @@
import type { RefObject } from 'react' import type { RefObject } from 'react'
import type { ChatConfig } from '@/app/components/base/chat/types' import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' 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 { 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 ChatWithHistory from '@/app/components/base/chat/chat-with-history'
import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks' import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks'
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context' import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'

View File

@ -1,5 +1,6 @@
import { render, screen, waitFor } from '@testing-library/react' import { screen, waitFor } from '@testing-library/react'
import * as React from 'react' import * as React from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context' import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control' 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() const mockGetProcessedSystemVariablesFromUrlParams = vi.fn()
vi.mock('@/app/components/base/chat/utils', () => ({ vi.mock('@/app/components/base/chat/utils', () => ({
getProcessedSystemVariablesFromUrlParams: (...args: any[]) => mockGetProcessedSystemVariablesFromUrlParams(...args), 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 TestConsumer = () => {
const embeddedUserId = useWebAppStore(state => state.embeddedUserId) const embeddedUserId = useWebAppStore(state => state.embeddedUserId)
const embeddedConversationId = useWebAppStore(state => state.embeddedConversationId) const embeddedConversationId = useWebAppStore(state => state.embeddedConversationId)
@ -91,7 +60,6 @@ const initialWebAppStore = (() => {
})() })()
beforeEach(() => { beforeEach(() => {
mockGlobalStoreState.isGlobalPending = false
mockGetProcessedSystemVariablesFromUrlParams.mockReset() mockGetProcessedSystemVariablesFromUrlParams.mockReset()
useWebAppStore.setState(initialWebAppStore, true) useWebAppStore.setState(initialWebAppStore, true)
}) })
@ -103,7 +71,7 @@ describe('WebAppStoreProvider embedded user id handling', () => {
conversation_id: 'conversation-456', conversation_id: 'conversation-456',
}) })
render( renderWithSystemFeatures(
<WebAppStoreProvider> <WebAppStoreProvider>
<TestConsumer /> <TestConsumer />
</WebAppStoreProvider>, </WebAppStoreProvider>,
@ -125,7 +93,7 @@ describe('WebAppStoreProvider embedded user id handling', () => {
})) }))
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({}) mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({})
render( renderWithSystemFeatures(
<WebAppStoreProvider> <WebAppStoreProvider>
<TestConsumer /> <TestConsumer />
</WebAppStoreProvider>, </WebAppStoreProvider>,

View File

@ -7,7 +7,8 @@
import type { Mock } from 'vitest' import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/models/explore' 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 AppList from '@/app/components/explore/app-list'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { fetchAppDetail } from '@/service/explore' import { fetchAppDetail } from '@/service/explore'

View File

@ -1,6 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import AccountDropdown from '@/app/components/header/account-dropdown' import AccountDropdown from '@/app/components/header/account-dropdown'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
@ -52,20 +52,6 @@ vi.mock('@/context/provider-context', () => ({
}), }),
})) }))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
const state = {
systemFeatures: {
branding: {
enabled: false,
workspace_logo: null,
},
},
}
return selector ? selector(state) : state
},
}))
vi.mock('@/context/modal-context', () => ({ vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({ useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal, setShowAccountSettingModal: mockSetShowAccountSettingModal,
@ -108,18 +94,14 @@ vi.mock('@/next/link', () => ({
})) }))
const renderAccountDropdown = () => { const renderAccountDropdown = () => {
const queryClient = new QueryClient({ return renderWithSystemFeatures(<AccountDropdown />, {
defaultOptions: { systemFeatures: {
queries: { retry: false }, branding: {
mutations: { retry: false }, enabled: false,
workspace_logo: '',
},
}, },
}) })
return render(
<QueryClientProvider client={queryClient}>
<AccountDropdown />
</QueryClientProvider>,
)
} }
describe('Header Account Dropdown Flow', () => { describe('Header Account Dropdown Flow', () => {

View File

@ -1,16 +1,7 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it } from 'vitest'
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit' import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
import { InstallationScope } from '@/types/feature' 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('Plugin Marketplace to Install Flow', () => {
describe('install permission validation pipeline', () => { describe('install permission validation pipeline', () => {
const systemFeaturesAll = { const systemFeaturesAll = {

View File

@ -1,7 +1,9 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import PluginPage from '@/app/components/plugins/plugin-page' import PluginPage from '@/app/components/plugins/plugin-page'
import { renderWithNuqs } from '@/test/nuqs-testing' import { createNuqsTestWrapper } from '@/test/nuqs-testing'
const mockFetchManifestFromMarketPlace = vi.fn() const mockFetchManifestFromMarketPlace = vi.fn()
@ -35,17 +37,6 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
systemFeatures: {
enable_marketplace: true,
plugin_installation_permission: {
restrict_to_marketplace_only: false,
},
},
}),
}))
vi.mock('@/service/use-plugins', () => ({ vi.mock('@/service/use-plugins', () => ({
useReferenceSettings: () => ({ useReferenceSettings: () => ({
data: { data: {
@ -104,13 +95,30 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () =
})) }))
const renderPluginPage = (searchParams = '') => { const renderPluginPage = (searchParams = '') => {
return renderWithNuqs( const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
<PluginPage systemFeatures: {
plugins={<div data-testid="plugins-view">plugins view</div>} enable_marketplace: true,
marketplace={<div data-testid="marketplace-view">marketplace view</div>} plugin_installation_permission: {
/>, restrict_to_marketplace_only: false,
{ searchParams }, },
},
})
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
const Wrapper = ({ children }: { children: ReactNode }) => (
<NuqsWrapper>
<SysWrapper>{children}</SysWrapper>
</NuqsWrapper>
) )
return {
...render(
<PluginPage
plugins={<div data-testid="plugins-view">plugins view</div>}
marketplace={<div data-testid="marketplace-view">marketplace view</div>}
/>,
{ wrapper: Wrapper },
),
onUrlUpdate,
}
} }
describe('Plugin Page Shell Flow', () => { describe('Plugin Page Shell Flow', () => {

View File

@ -1,6 +1,7 @@
import type { AccessMode } from '@/models/access-control' 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 * as React from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import TextGeneration from '@/app/components/share/text-generation' import TextGeneration from '@/app/components/share/text-generation'
const useSearchParamsMock = vi.fn(() => new URLSearchParams()) const useSearchParamsMock = vi.fn(() => new URLSearchParams())
@ -117,7 +118,7 @@ vi.mock('@/service/share', async () => {
const mockSystemFeatures = { const mockSystemFeatures = {
branding: { branding: {
enabled: false, enabled: false,
workspace_logo: null, workspace_logo: '',
}, },
} }
@ -170,11 +171,6 @@ const mockWebAppState = {
webAppAccessMode: 'public' as AccessMode, 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', () => ({ vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => selector(mockWebAppState), 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 () => { it('should switch between create, batch, and saved tabs after app state loads', async () => {
render(<TextGeneration />) renderWithSystemFeatures(<TextGeneration />, { systemFeatures: mockSystemFeatures })
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() 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 () => { it('should wire single-run stop control and clear it when batch execution starts', async () => {
render(<TextGeneration />) renderWithSystemFeatures(<TextGeneration />, { systemFeatures: mockSystemFeatures })
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()

View File

@ -1,8 +1,10 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import ProviderList from '@/app/components/tools/provider-list' import ProviderList from '@/app/components/tools/provider-list'
import { CollectionType } from '@/app/components/tools/types' import { CollectionType } from '@/app/components/tools/types'
import { renderWithNuqs } from '@/test/nuqs-testing' import { createNuqsTestWrapper } from '@/test/nuqs-testing'
const mockInvalidateInstalledPluginList = vi.fn() const mockInvalidateInstalledPluginList = vi.fn()
@ -12,14 +14,6 @@ vi.mock('react-i18next', () => ({
}), }),
})) }))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
systemFeatures: {
enable_marketplace: true,
},
}),
}))
vi.mock('@/app/components/plugins/hooks', () => ({ vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({ useTags: () => ({
getTagLabel: (name: string) => name, getTagLabel: (name: string) => name,
@ -159,7 +153,16 @@ vi.mock('@/app/components/tools/mcp', () => ({
})) }))
const renderProviderList = (searchParams = '') => { const renderProviderList = (searchParams = '') => {
return renderWithNuqs(<ProviderList />, { searchParams }) const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
systemFeatures: { enable_marketplace: true },
})
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
const Wrapper = ({ children }: { children: ReactNode }) => (
<NuqsWrapper>
<SysWrapper>{children}</SysWrapper>
</NuqsWrapper>
)
return { ...render(<ProviderList />, { wrapper: Wrapper }), onUrlUpdate }
} }
describe('Tool Provider List Shell Flow', () => { describe('Tool Provider List Shell Flow', () => {

View File

@ -6,10 +6,10 @@ import type { Collection } from '@/app/components/tools/types'
* Input (search), and card rendering. Verifies that tab switching, keyword * Input (search), and card rendering. Verifies that tab switching, keyword
* filtering, and label filtering work together correctly. * 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 { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { CollectionType } from '@/app/components/tools/types' import { CollectionType } from '@/app/components/tools/types'
// ---- Mocks ---- // ---- 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', () => ({ vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({ useTags: () => ({
getTagLabel: (key: string) => key, 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 { default: ProviderList } = await import('@/app/components/tools/provider-list')
const createWrapper = () => { const createWrapper = () => {
const queryClient = new QueryClient({ const { wrapper } = createSystemFeaturesWrapper({
defaultOptions: { queries: { retry: false } }, systemFeatures: { enable_marketplace: false },
}) })
return ({ children }: { children: React.ReactNode }) => ( return wrapper
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
} }
describe('Tool Browsing & Filtering Integration', () => { describe('Tool Browsing & Filtering Integration', () => {

View File

@ -0,0 +1,127 @@
import type { RenderHookOptions, RenderHookResult, RenderOptions, RenderResult } from '@testing-library/react'
import type { ReactElement, ReactNode } from 'react'
import type { SystemFeatures } from '@/types/feature'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, renderHook } from '@testing-library/react'
import { consoleQuery } from '@/service/client'
import { defaultSystemFeatures } from '@/types/feature'
type DeepPartial<T> = T extends Array<infer U>
? Array<U>
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T
const buildSystemFeatures = (
overrides: DeepPartial<SystemFeatures> = {},
): SystemFeatures => {
const o = overrides as Partial<SystemFeatures>
return {
...defaultSystemFeatures,
...o,
branding: {
...defaultSystemFeatures.branding,
...(o.branding ?? {}),
},
webapp_auth: {
...defaultSystemFeatures.webapp_auth,
...(o.webapp_auth ?? {}),
sso_config: {
...defaultSystemFeatures.webapp_auth.sso_config,
...(o.webapp_auth?.sso_config ?? {}),
},
},
plugin_installation_permission: {
...defaultSystemFeatures.plugin_installation_permission,
...(o.plugin_installation_permission ?? {}),
},
license: {
...defaultSystemFeatures.license,
...(o.license ?? {}),
},
}
}
/**
* Build a QueryClient suitable for tests. Any unseeded query stays in the
* "pending" state forever because the default queryFn never resolves; this
* mirrors the behaviour of an in-flight network request without touching the
* real fetch layer.
*/
export const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: Infinity,
queryFn: () => new Promise(() => {}),
},
mutations: { retry: false },
},
})
export const seedSystemFeatures = (
queryClient: QueryClient,
overrides: DeepPartial<SystemFeatures> = {},
): SystemFeatures => {
const data = buildSystemFeatures(overrides)
queryClient.setQueryData(consoleQuery.systemFeatures.queryKey(), data)
return data
}
type SystemFeaturesTestOptions = {
/**
* Partial overrides for the systemFeatures payload. When omitted, the cache
* is seeded with `defaultSystemFeatures` so consumers using
* `useSuspenseQuery` resolve immediately. Pass `null` to skip seeding and
* keep the systemFeatures query in the pending state.
*/
systemFeatures?: DeepPartial<SystemFeatures> | null
queryClient?: QueryClient
}
type SystemFeaturesWrapper = {
queryClient: QueryClient
systemFeatures: SystemFeatures | null
wrapper: (props: { children: ReactNode }) => ReactElement
}
export const createSystemFeaturesWrapper = (
options: SystemFeaturesTestOptions = {},
): SystemFeaturesWrapper => {
const queryClient = options.queryClient ?? createTestQueryClient()
const systemFeatures = options.systemFeatures === null
? null
: seedSystemFeatures(queryClient, options.systemFeatures)
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { queryClient, systemFeatures, wrapper }
}
export const renderWithSystemFeatures = (
ui: ReactElement,
options: SystemFeaturesTestOptions & Omit<RenderOptions, 'wrapper'> = {},
): RenderResult & { queryClient: QueryClient, systemFeatures: SystemFeatures | null } => {
const { systemFeatures: sf, queryClient: qc, ...renderOptions } = options
const { wrapper, queryClient, systemFeatures } = createSystemFeaturesWrapper({
systemFeatures: sf,
queryClient: qc,
})
const rendered = render(ui, { wrapper, ...renderOptions })
return { ...rendered, queryClient, systemFeatures }
}
export const renderHookWithSystemFeatures = <Result, Props = void>(
callback: (props: Props) => Result,
options: SystemFeaturesTestOptions & Omit<RenderHookOptions<Props>, 'wrapper'> = {},
): RenderHookResult<Result, Props> & { queryClient: QueryClient, systemFeatures: SystemFeatures | null } => {
const { systemFeatures: sf, queryClient: qc, ...hookOptions } = options
const { wrapper, queryClient, systemFeatures } = createSystemFeaturesWrapper({
systemFeatures: sf,
queryClient: qc,
})
const rendered = renderHook(callback, { wrapper, ...hookOptions })
return { ...rendered, queryClient, systemFeatures }
}

View File

@ -0,0 +1,33 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import RootLoading from '@/app/loading'
import { isLegacyBase401 } from '@/service/use-common'
type Props = {
error: Error & { digest?: string }
unstable_retry: () => void
}
export default function CommonLayoutError({ error, unstable_retry }: Props) {
const { t } = useTranslation('common')
// 401 already triggered jumpTo(/signin) inside service/base.ts. Render Loading
// until the browser navigation completes, matching main's Splash behavior.
// Showing the "Try again" button here would just flash for a few frames before
// the page navigates away, and clicking it would 401 again anyway.
if (isLegacyBase401(error))
return <RootLoading />
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body">
<div className="system-sm-regular text-text-tertiary">
{t('errorBoundary.message')}
</div>
<Button size="small" variant="secondary" onClick={() => unstable_retry()}>
{t('errorBoundary.tryAgain')}
</Button>
</div>
)
}

View File

@ -14,7 +14,6 @@ import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider' import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider' import { ProviderContextProvider } from '@/context/provider-context-provider'
import PartnerStack from '../components/billing/partner-stack' import PartnerStack from '../components/billing/partner-stack'
import Splash from '../components/splash'
import RoleRouteGuard from './role-route-guard' import RoleRouteGuard from './role-route-guard'
const Layout = ({ children }: { children: ReactNode }) => { const Layout = ({ children }: { children: ReactNode }) => {
@ -37,7 +36,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
<PartnerStack /> <PartnerStack />
<ReadmePanel /> <ReadmePanel />
<GotoAnything /> <GotoAnything />
<Splash />
</ModalContextProvider> </ModalContextProvider>
</ProviderContextProvider> </ProviderContextProvider>
</EventEmitterContextProvider> </EventEmitterContextProvider>

View File

@ -0,0 +1,9 @@
import Loading from '@/app/components/base/loading'
export default function CommonLayoutLoading() {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background-body">
<Loading />
</div>
)
}

View File

@ -1,11 +1,12 @@
'use client' 'use client'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import Header from '@/app/signin/_header' 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) { export default function SignInLayout({ children }: any) {
const { systemFeatures } = useGlobalPublicStore() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
return ( return (
<> <>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>

View File

@ -1,16 +1,17 @@
'use client' 'use client'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import AppUnavailable from '@/app/components/base/app-unavailable' import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useRouter, useSearchParams } from '@/next/navigation' import { useRouter, useSearchParams } from '@/next/navigation'
import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { SSOProtocol } from '@/types/feature' import { SSOProtocol } from '@/types/feature'
const ExternalMemberSSOAuth = () => { const ExternalMemberSSOAuth = () => {
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()

View File

@ -2,13 +2,14 @@
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { systemFeaturesQueryOptions } from '@/service/system-features'
export default function SignInLayout({ children }: PropsWithChildren) { export default function SignInLayout({ children }: PropsWithChildren) {
const { t } = useTranslation() const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
useDocumentTitle(t('webapp.login', { ns: 'login' })) useDocumentTitle(t('webapp.login', { ns: 'login' }))
return ( return (
<> <>

View File

@ -1,13 +1,14 @@
'use client' 'use client'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react' import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { IS_CE_EDITION } from '@/config' import { IS_CE_EDITION } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import Link from '@/next/link' import Link from '@/next/link'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { LicenseStatus } from '@/types/feature' import { LicenseStatus } from '@/types/feature'
import MailAndCodeAuth from './components/mail-and-code-auth' import MailAndCodeAuth from './components/mail-and-code-auth'
import MailAndPasswordAuth from './components/mail-and-password-auth' import MailAndPasswordAuth from './components/mail-and-password-auth'
@ -17,7 +18,7 @@ const NormalForm = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const { systemFeatures } = useGlobalPublicStore() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const [authType, updateAuthType] = useState<'code' | 'password'>('password') const [authType, updateAuthType] = useState<'code' | 'password'>('password')
const [showORLine, setShowORLine] = useState(false) const [showORLine, setShowORLine] = useState(false)
const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false) const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false)

View File

@ -1,20 +1,21 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react' import * as React from 'react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AppUnavailable from '@/app/components/base/app-unavailable' import AppUnavailable from '@/app/components/base/app-unavailable'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWebAppStore } from '@/context/web-app-context' import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import { useRouter, useSearchParams } from '@/next/navigation' import { useRouter, useSearchParams } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { webAppLogout } from '@/service/webapp-auth' import { webAppLogout } from '@/service/webapp-auth'
import ExternalMemberSsoAuth from './components/external-member-sso-auth' import ExternalMemberSsoAuth from './components/external-member-sso-auth'
import NormalForm from './normalForm' import NormalForm from './normalForm'
const WebSSOForm: FC = () => { const WebSSOForm: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()

View File

@ -7,7 +7,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { import {
RiGraduationCapFill, RiGraduationCapFill,
} from '@remixicon/react' } from '@remixicon/react'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon' 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 PremiumBadge from '@/app/components/base/premium-badge'
import Collapse from '@/app/components/header/account-setting/collapse' import Collapse from '@/app/components/header/account-setting/collapse'
import { IS_CE_EDITION, validPassword } from '@/config' import { IS_CE_EDITION, validPassword } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { updateUserProfile } from '@/service/common' import { updateUserProfile } from '@/service/common'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useAppList } from '@/service/use-apps' 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 DeleteAccount from '../delete-account'
import AvatarWithEdit from './AvatarWithEdit' import AvatarWithEdit from './AvatarWithEdit'
@ -34,12 +34,13 @@ const descriptionClassName = `
export default function AccountPage() { export default function AccountPage() {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: appList } = useAppList({ page: 1, limit: 100, name: '' }) const { data: appList } = useAppList({ page: 1, limit: 100, name: '' })
const apps = appList?.data || [] const apps = appList?.data || []
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: userProfileResp } = useUserProfile() // Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously.
const userProfile = userProfileResp?.profile const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const userProfile = userProfileResp.profile
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile }) const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile })
const { isEducationAccount } = useProviderContext() const { isEducationAccount } = useProviderContext()
const [editNameModalVisible, setEditNameModalVisible] = useState(false) const [editNameModalVisible, setEditNameModalVisible] = useState(false)

View File

@ -4,6 +4,7 @@ import { Avatar } from '@langgenius/dify-ui/avatar'
import { import {
RiGraduationCapFill, RiGraduationCapFill,
} from '@remixicon/react' } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { Fragment } from 'react' import { Fragment } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils' 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 PremiumBadge from '@/app/components/base/premium-badge'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useRouter } from '@/next/navigation' import { useRouter } from '@/next/navigation'
import { useLogout, useUserProfile } from '@/service/use-common' import { useLogout, userProfileQueryOptions } from '@/service/use-common'
export default function AppSelector() { export default function AppSelector() {
const router = useRouter() const router = useRouter()
const { t } = useTranslation() const { t } = useTranslation()
const { data: userProfileResp } = useUserProfile() // Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously.
const userProfile = userProfileResp?.profile const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const userProfile = userProfileResp.profile
const { isEducationAccount } = useProviderContext() const { isEducationAccount } = useProviderContext()
const { mutateAsync: logout } = useLogout() const { mutateAsync: logout } = useLogout()

View File

@ -1,17 +1,18 @@
'use client' 'use client'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { RiArrowRightUpLine, RiRobot2Line } from '@remixicon/react' import { RiArrowRightUpLine, RiRobot2Line } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import DifyLogo from '@/app/components/base/logo/dify-logo' import DifyLogo from '@/app/components/base/logo/dify-logo'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useRouter } from '@/next/navigation' import { useRouter } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import Avatar from './avatar' import Avatar from './avatar'
const Header = () => { const Header = () => {
const { t } = useTranslation() const { t } = useTranslation()
const router = useRouter() const router = useRouter()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const goToStudio = useCallback(() => { const goToStudio = useCallback(() => {
router.push('/apps') router.push('/apps')

View File

@ -1,20 +1,27 @@
'use client' 'use client'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import Header from '@/app/signin/_header' import Header from '@/app/signin/_header'
import { AppContextProvider } from '@/context/app-context-provider' import { AppContextProvider } from '@/context/app-context-provider'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title' 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) { export default function SignInLayout({ children }: any) {
const { systemFeatures } = useGlobalPublicStore() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
useDocumentTitle('') useDocumentTitle('')
const { isLoading, data: loginData } = useIsLogin() // Probe login state. 401 stays as `error` (not thrown) so this layout can render
const isLoggedIn = loginData?.logged_in // 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 ( return (
<div className="flex min-h-screen w-full justify-center bg-background-default-burn"> <div className="flex min-h-screen w-full justify-center bg-background-default-burn">
<Loading /> <Loading />

View File

@ -10,6 +10,7 @@ import {
RiMailLine, RiMailLine,
RiTranslate2, RiTranslate2,
} from '@remixicon/react' } from '@remixicon/react'
import { useQuery } from '@tanstack/react-query'
import * as React from 'react' import * as React from 'react'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' 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 { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect' import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
import { useRouter, useSearchParams } from '@/next/navigation' 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' import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
function buildReturnUrl(pathname: string, search: string) { function buildReturnUrl(pathname: string, search: string) {
@ -61,15 +62,20 @@ export default function OAuthAuthorize() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const client_id = decodeURIComponent(searchParams.get('client_id') || '') const client_id = decodeURIComponent(searchParams.get('client_id') || '')
const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '') 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 userProfile = userProfileResp?.profile
const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri) const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri)
const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp() const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp()
const hasNotifiedRef = useRef(false) const hasNotifiedRef = useRef(false)
const { isLoading: isIsLoginLoading, data: loginData } = useIsLogin() const isLoading = isOAuthLoading || isProfileLoading
const isLoggedIn = loginData?.logged_in
const isLoading = isOAuthLoading || isIsLoginLoading
const onLoginSwitchClick = () => { const onLoginSwitchClick = () => {
try { try {
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`) const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`)

View File

@ -1,12 +1,13 @@
'use client' 'use client'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react' import * as React from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context' import { systemFeaturesQueryOptions } from '@/service/system-features'
import Header from '../signin/_header' import Header from '../signin/_header'
import ActivateForm from './activateForm' import ActivateForm from './activateForm'
const Activate = () => { const Activate = () => {
const { systemFeatures } = useGlobalPublicStore() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
return ( return (
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>

View File

@ -1,59 +0,0 @@
import type { MockedFunction } from 'vitest'
import { render, screen } from '@testing-library/react'
import { useUserProfile } from '@/service/use-common'
import Splash from '../splash'
vi.mock('@/service/use-common', () => ({
useUserProfile: vi.fn(),
}))
const mockUseUserProfile = useUserProfile as MockedFunction<typeof useUserProfile>
describe('Splash', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the loading indicator while the profile query is pending', () => {
mockUseUserProfile.mockReturnValue({
isPending: true,
isError: false,
data: undefined,
} as ReturnType<typeof useUserProfile>)
render(<Splash />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should not render the loading indicator when the profile query succeeds', () => {
mockUseUserProfile.mockReturnValue({
isPending: false,
isError: false,
data: {
profile: { id: 'user-1' },
meta: {
currentVersion: '1.13.3',
currentEnv: 'DEVELOPMENT',
},
},
} as ReturnType<typeof useUserProfile>)
render(<Splash />)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('should stop rendering the loading indicator when the profile query errors', () => {
mockUseUserProfile.mockReturnValue({
isPending: false,
isError: true,
data: undefined,
error: new Error('profile request failed'),
} as ReturnType<typeof useUserProfile>)
render(<Splash />)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})

View File

@ -8,6 +8,7 @@ import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants' } from '@/app/education-apply/constants'
import RootLoading from '@/app/loading'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { sendGAEvent } from '@/utils/gtag' import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status' import { fetchSetupStatusWithCache } from '@/utils/setup-status'
@ -98,5 +99,5 @@ export const AppInitializer = ({
})() })()
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser]) }, [isSetupFinished, router, pathname, searchParams, oauthNewUser])
return init ? children : null return init ? children : <RootLoading />
} }

View File

@ -2,8 +2,9 @@
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import type { App } from '@/types/app' import type { App } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import useAccessControlStore from '@/context/access-control-store' import useAccessControlStore from '@/context/access-control-store'
import { AccessMode, SubjectType } from '@/models/access-control' import { AccessMode, SubjectType } from '@/models/access-control'
import AccessControlDialog from '../access-control-dialog' import AccessControlDialog from '../access-control-dialog'

View File

@ -1,10 +1,23 @@
import type { ReactElement } from 'react'
import type { App } from '@/types/app' import type { App } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import useAccessControlStore from '@/context/access-control-store' import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import AccessControl from '../index' import AccessControl from '../index'
let mockWebappAuth = {
enabled: true,
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
}
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: { webapp_auth: mockWebappAuth },
})
const mockMutateAsync = vi.fn() const mockMutateAsync = vi.fn()
const mockUseUpdateAccessMode = vi.fn(() => ({ const mockUseUpdateAccessMode = vi.fn(() => ({
isPending: false, isPending: false,
@ -12,20 +25,6 @@ const mockUseUpdateAccessMode = vi.fn(() => ({
})) }))
const mockUseAppWhiteListSubjects = vi.fn() const mockUseAppWhiteListSubjects = vi.fn()
const mockUseSearchForWhiteListCandidates = 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', () => ({ vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),

View File

@ -5,11 +5,12 @@ import { Description as DialogDescription, DialogTitle } from '@headlessui/react
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react' import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AccessMode, SubjectType } from '@/models/access-control' import { AccessMode, SubjectType } from '@/models/access-control'
import { useUpdateAccessMode } from '@/service/access-control' import { useUpdateAccessMode } from '@/service/access-control'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import useAccessControlStore from '../../../../context/access-control-store' import useAccessControlStore from '../../../../context/access-control-store'
import AccessControlDialog from './access-control-dialog' import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item' import AccessControlItem from './access-control-item'
@ -24,7 +25,7 @@ type AccessControlProps = {
export default function AccessControl(props: AccessControlProps) { export default function AccessControl(props: AccessControlProps) {
const { app, onClose, onConfirm } = props const { app, onClose, onConfirm } = props
const { t } = useTranslation() const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const setAppId = useAccessControlStore(s => s.setAppId) const setAppId = useAccessControlStore(s => s.setAppId)
const specificGroups = useAccessControlStore(s => s.specificGroups) const specificGroups = useAccessControlStore(s => s.specificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers) const specificMembers = useAccessControlStore(s => s.specificMembers)

View File

@ -1,11 +1,16 @@
/* eslint-disable ts/no-explicit-any */ /* 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 * as React from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import { AppModeEnum, AppTypeEnum } from '@/types/app' import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { basePath } from '@/utils/var' import { basePath } from '@/utils/var'
import AppPublisher from '../index' import AppPublisher from '../index'
const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: { webapp_auth: { enabled: true } },
})
const mockOnPublish = vi.fn() const mockOnPublish = vi.fn()
const mockOnToggle = vi.fn() const mockOnToggle = vi.fn()
const mockSetAppDetail = vi.fn() const mockSetAppDetail = vi.fn()
@ -53,16 +58,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', () => ({ vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({ useFormatTimeFromNow: () => ({
formatTimeFromNow: () => 'moments ago', formatTimeFromNow: () => 'moments ago',

View File

@ -7,6 +7,7 @@ import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/type
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useKeyPress } from 'ahooks' import { useKeyPress } from 'ahooks'
import { import {
memo, memo,
@ -23,7 +24,6 @@ import { trackEvent } from '@/app/components/base/amplitude'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { WorkflowContext } from '@/app/components/workflow/context' import { WorkflowContext } from '@/app/components/workflow/context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access' import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
@ -31,6 +31,7 @@ import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps' import { fetchAppDetailDirect } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore' import { fetchInstalledAppList } from '@/service/explore'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useConvertWorkflowTypeMutation } from '@/service/use-apps' import { useConvertWorkflowTypeMutation } from '@/service/use-apps'
import { useEvaluationWorkflowAssociatedTargets } from '@/service/use-evaluation' import { useEvaluationWorkflowAssociatedTargets } from '@/service/use-evaluation'
import { useInvalidateAppWorkflow } from '@/service/use-workflow' import { useInvalidateAppWorkflow } from '@/service/use-workflow'
@ -137,8 +138,8 @@ const AppPublisher = ({
const workflowStore = useContext(WorkflowContext) const workflowStore = useContext(WorkflowContext)
const appDetail = useAppStore(state => state.appDetail) const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(s => s.setAppDetail) const setAppDetail = useAppStore(s => s.setAppDetail)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess() const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { formatTimeFromNow } = useFormatTimeFromNow() const { formatTimeFromNow } = useFormatTimeFromNow()
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation() const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation()

View File

@ -1,8 +1,9 @@
/* eslint-disable ts/no-explicit-any */ /* eslint-disable ts/no-explicit-any */
import type { App } from '@/models/explore' import type { App } from '@/models/explore'
import type { AppIconType } from '@/types/app' import type { AppIconType } from '@/types/app'
import { render, screen, within } from '@testing-library/react' import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import { trackEvent } from '@/app/components/base/amplitude' import { trackEvent } from '@/app/components/base/amplitude'
import AppListContext from '@/context/app-list-context' import AppListContext from '@/context/app-list-context'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'

View File

@ -4,13 +4,14 @@ import { PlusIcon } from '@heroicons/react/20/solid'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { RiInformation2Line } from '@remixicon/react' import { RiInformation2Line } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector' import { useContextSelector } from 'use-context-selector'
import { trackEvent } from '@/app/components/base/amplitude' import { trackEvent } from '@/app/components/base/amplitude'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
import AppListContext from '@/context/app-list-context' import AppListContext from '@/context/app-list-context'
import { useGlobalPublicStore } from '@/context/global-public-context' import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppTypeIcon, AppTypeLabel } from '../../type-selector' import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
type AppCardProps = { type AppCardProps = {
@ -26,7 +27,7 @@ const AppCard = ({
}: AppCardProps) => { }: AppCardProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { app: appBasicInfo } = app const { app: appBasicInfo } = app
const { systemFeatures } = useGlobalPublicStore() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel) const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel)
const handleShowTryAppPanel = useCallback(() => { const handleShowTryAppPanel = useCallback(() => {

View File

@ -1,11 +1,16 @@
import type { ReactNode } from 'react' import type { ReactElement, ReactNode } from 'react'
import type { AppDetailResponse } from '@/models/app' 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 { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var' import { basePath } from '@/utils/var'
import AppCard from '../app-card' import AppCard from '../app-card'
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: { webapp_auth: { enabled: true } },
})
const mockFetchAppDetailDirect = vi.fn() const mockFetchAppDetailDirect = vi.fn()
const mockPush = vi.fn() const mockPush = vi.fn()
const mockSetAppDetail = vi.fn() const mockSetAppDetail = vi.fn()
@ -36,16 +41,6 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`, 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', () => ({ vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({ useStore: (selector: (state: { appDetail: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
appDetail: mockAppDetail as AppDetailResponse, appDetail: mockAppDetail as AppDetailResponse,

View File

@ -3,6 +3,7 @@ import type { ConfigParams } from './settings'
import type { AppDetailResponse } from '@/models/app' import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app' import type { AppSSO } from '@/types/app'
import { Switch } from '@langgenius/dify-ui/switch' import { Switch } from '@langgenius/dify-ui/switch'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react' import * as React from 'react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -12,12 +13,12 @@ import Tooltip from '@/app/components/base/tooltip'
import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button' import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button'
import Indicator from '@/app/components/header/indicator' import Indicator from '@/app/components/header/indicator'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n' import { useDocLink } from '@/context/i18n'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import { usePathname, useRouter } from '@/next/navigation' import { usePathname, useRouter } from '@/next/navigation'
import { useAppWhiteListSubjects } from '@/service/access-control' import { useAppWhiteListSubjects } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps' import { fetchAppDetailDirect } from '@/service/apps'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useAppWorkflow } from '@/service/use-workflow' import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils' import { asyncRunSafe } from '@/utils'
@ -73,7 +74,7 @@ function AppCard({
const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showAccessControl, setShowAccessControl] = useState(false) const [showAccessControl, setShowAccessControl] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: appAccessSubjects } = useAppWhiteListSubjects( const { data: appAccessSubjects } = useAppWhiteListSubjects(
appDetail?.id, appDetail?.id,
systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS, systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS,

View File

@ -1,7 +1,8 @@
import type { Mock } from 'vitest' import type { Mock } from 'vitest'
import type { App } from '@/types/app' 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 * as React from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import * as appsService from '@/service/apps' import * as appsService from '@/service/apps'
import * as exploreService from '@/service/explore' import * as exploreService from '@/service/explore'
@ -9,6 +10,15 @@ import * as workflowService from '@/service/workflow'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import AppCard from '../app-card' 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 // Mock next/navigation
const mockPush = vi.fn() const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({ vi.mock('@/next/navigation', () => ({
@ -65,16 +75,7 @@ vi.mock('@/context/provider-context', () => ({
}), }),
})) }))
// Mock global public store - allow dynamic configuration // systemFeatures is seeded into the QueryClient via the local render helper.
let mockWebappAuthEnabled = false
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (s: Record<string, unknown>) => unknown) => selector({
systemFeatures: {
webapp_auth: { enabled: mockWebappAuthEnabled },
branding: { enabled: false },
},
}),
}))
vi.mock('@/service/apps', () => ({ vi.mock('@/service/apps', () => ({
deleteApp: vi.fn(() => Promise.resolve()), deleteApp: vi.fn(() => Promise.resolve()),

View File

@ -1,5 +1,6 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from '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 { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { renderWithNuqs } from '@/test/nuqs-testing' import { renderWithNuqs } from '@/test/nuqs-testing'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
@ -293,14 +294,19 @@ beforeAll(() => {
} as unknown as typeof IntersectionObserver } as unknown as typeof IntersectionObserver
}) })
// Render helper wrapping with shared nuqs testing helper plus a seeded
// systemFeatures cache so List can resolve its useSuspenseQuery.
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => { const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
return renderWithNuqs(<List {...props} />, { searchParams }) const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
return renderWithNuqs(<SystemFeaturesWrapper><List {...props} /></SystemFeaturesWrapper>, { searchParams })
} }
describe('List', () => { describe('List', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
defaultSnippetData.pages[0].data = [ defaultSnippetData.pages[0]!.data = [
{ {
id: 'snippet-1', id: 'snippet-1',
name: 'Tone Rewriter', name: 'Tone Rewriter',
@ -319,7 +325,7 @@ describe('List', () => {
author: '', author: '',
}, },
] ]
defaultSnippetData.pages[0].total = 1 defaultSnippetData.pages[0]!.total = 1
useTagStore.setState({ useTagStore.setState({
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }], tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
showTagManagementModal: false, showTagManagementModal: false,
@ -371,7 +377,7 @@ describe('List', () => {
fireEvent.click(await screen.findByText('app.types.workflow')) fireEvent.click(await screen.findByText('app.types.workflow'))
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) 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) expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
}) })
@ -465,7 +471,7 @@ describe('List', () => {
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => { it('should handle multiple renders without issues', () => {
const { unmount } = renderWithNuqs(<List />) const { unmount } = renderList()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument() expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
unmount() unmount()

View File

@ -24,6 +24,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu' } from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useId, useMemo, useState } from 'react' import { useCallback, useEffect, useId, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next' 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 { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
@ -44,6 +44,7 @@ import { useRouter } from '@/next/navigation'
import { useGetUserCanAccessApp } from '@/service/access-control' import { useGetUserCanAccessApp } from '@/service/access-control'
import { copyApp, exportAppConfig, updateAppInfo } from '@/service/apps' import { copyApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore' import { fetchInstalledAppList } from '@/service/explore'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useDeleteAppMutation } from '@/service/use-apps' import { useDeleteAppMutation } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow' import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
@ -182,7 +183,7 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
type AppCardOperationsMenuContentProps = Omit<AppCardOperationsMenuProps, 'shouldShowOpenInExploreOption'> type AppCardOperationsMenuContentProps = Omit<AppCardOperationsMenuProps, 'shouldShowOpenInExploreOption'>
const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps> = (props) => { const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps> = (props) => {
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({
appId: props.app.id, appId: props.app.id,
enabled: systemFeatures.webapp_auth.enabled, enabled: systemFeatures.webapp_auth.enabled,
@ -205,7 +206,7 @@ const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps>
const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const deleteAppNameInputId = useId() const deleteAppNameInputId = useId()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor } = useAppContext() const { isCurrentWorkspaceEditor } = useAppContext()
const { onPlanInfoChanged } = useProviderContext() const { onPlanInfoChanged } = useProviderContext()
const { push } = useRouter() const { push } = useRouter()

View File

@ -4,6 +4,7 @@ import type { FC } from 'react'
import type { StudioPageType } from '.' import type { StudioPageType } from '.'
import type { WorkflowOnlineUser } from '@/models/app' import type { WorkflowOnlineUser } from '@/models/app'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks' import { useDebounceFn } from 'ahooks'
import { useQueryState } from 'nuqs' import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay' import { CheckModal } from '@/hooks/use-pay'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access' import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import dynamic from '@/next/dynamic' import dynamic from '@/next/dynamic'
import { fetchWorkflowOnlineUsers } from '@/service/apps' import { fetchWorkflowOnlineUsers } from '@/service/apps'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInfiniteAppList } from '@/service/use-apps' import { useInfiniteAppList } from '@/service/use-apps'
import { useInfiniteSnippetList } from '@/service/use-snippets' import { useInfiniteSnippetList } from '@/service/use-snippets'
import SnippetCard from '../snippets/components/snippet-card' import SnippetCard from '../snippets/components/snippet-card'
@ -53,7 +54,7 @@ const List: FC<Props> = ({
const { t } = useTranslation() const { t } = useTranslation()
const isAppsPage = pageType === 'apps' const isAppsPage = pageType === 'apps'
const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess() const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const { systemFeatures } = useGlobalPublicStore() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [activeTab, setActiveTab] = useQueryState( const [activeTab, setActiveTab] = useQueryState(

View File

@ -2,8 +2,9 @@ import type { i18n } from 'i18next'
import type { ChatConfig } from '../../types' import type { ChatConfig } from '../../types'
import type { ChatWithHistoryContextValue } from '../context' import type { ChatWithHistoryContextValue } from '../context'
import type { AppData, AppMeta } from '@/models/share' 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 * as ReactI18next from 'react-i18next'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useChatWithHistoryContext } from '../context' import { useChatWithHistoryContext } from '../context'
import HeaderInMobile from '../header-in-mobile' import HeaderInMobile from '../header-in-mobile'

View File

@ -2,7 +2,8 @@ import type { RefObject } from 'react'
import type { ChatConfig } from '../../types' import type { ChatConfig } from '../../types'
import type { InstalledApp } from '@/models/explore' import type { InstalledApp } from '@/models/explore'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' 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 useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { useChatWithHistory } from '../hooks' import { useChatWithHistory } from '../hooks'

View File

@ -1,23 +1,18 @@
import type { ReactElement } from 'react'
import type { ChatWithHistoryContextValue } from '../../context' 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 userEvent from '@testing-library/user-event'
import * as React from 'react' import * as React from 'react'
import * as ReactI18next from 'react-i18next' 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 { useChatWithHistoryContext } from '../../context'
import Sidebar from '../index' import Sidebar from '../index'
import RenameModal from '../rename-modal' import RenameModal from '../rename-modal'
// Type for mocking the global public store selector let mockBranding: { enabled: boolean, workspace_logo: string } = { enabled: false, workspace_logo: '' }
type GlobalPublicStoreMock = { const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: { systemFeatures: { branding: { ...mockBranding } },
branding: { })
enabled: boolean
workspace_logo: string | null
}
}
setSystemFeatures?: (features: unknown) => void
}
function mockUseTranslationWithEmptyKeys(emptyKeys: string[]) { function mockUseTranslationWithEmptyKeys(emptyKeys: string[]) {
const originalUseTranslation = ReactI18next.useTranslation const originalUseTranslation = ReactI18next.useTranslation
@ -38,19 +33,6 @@ function mockUseTranslationWithEmptyKeys(emptyKeys: string[]) {
}) })
} }
// Helper to create properly-typed mock store state
function createMockStoreState(overrides: Partial<GlobalPublicStoreMock>): GlobalPublicStoreMock {
return {
systemFeatures: {
branding: {
enabled: false,
workspace_logo: null,
},
},
...overrides,
}
}
// Mock List to allow us to trigger operations // Mock List to allow us to trigger operations
vi.mock('../list', () => ({ 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 }) => ( 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(), 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 // Mock next/navigation
vi.mock('@/next/navigation', () => ({ vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }), useRouter: () => ({ push: vi.fn() }),
@ -139,8 +109,8 @@ describe('Sidebar Index', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockBranding = { enabled: false, workspace_logo: '' }
vi.mocked(useChatWithHistoryContext).mockReturnValue(mockContextValue) vi.mocked(useChatWithHistoryContext).mockReturnValue(mockContextValue)
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector(createMockStoreState({}) as never))
}) })
describe('Basic Rendering', () => { describe('Basic Rendering', () => {
@ -658,17 +628,7 @@ describe('Sidebar Index', () => {
}) })
it('should use system branding logo when enabled', () => { it('should use system branding logo when enabled', () => {
const mockStoreState = createMockStoreState({ mockBranding = { enabled: true, workspace_logo: 'http://example.com/workspace-logo.png' }
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))
vi.mocked(useChatWithHistoryContext).mockReturnValue({ vi.mocked(useChatWithHistoryContext).mockReturnValue({
...mockContextValue, ...mockContextValue,

View File

@ -15,6 +15,7 @@ import {
RiExpandRightLine, RiExpandRightLine,
RiLayoutLeft2Line, RiLayoutLeft2Line,
} from '@remixicon/react' } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { import {
useCallback, useCallback,
useState, 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 RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
import DifyLogo from '@/app/components/base/logo/dify-logo' import DifyLogo from '@/app/components/base/logo/dify-logo'
import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown' 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' import { useChatWithHistoryContext } from '../context'
type Props = { type Props = {
@ -55,7 +56,7 @@ const Sidebar = ({ isPanel, panelVisible }: Props) => {
isResponding, isResponding,
} = useChatWithHistoryContext() } = useChatWithHistoryContext()
const isSidebarCollapsed = sidebarCollapseState const isSidebarCollapsed = sidebarCollapseState
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null) const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
const [showRename, setShowRename] = useState<ConversationItem | null>(null) const [showRename, setShowRename] = useState<ConversationItem | null>(null)

View File

@ -1,14 +1,20 @@
import type { RefObject } from 'react' import type { ReactElement, RefObject } from 'react'
import type { ChatConfig } from '../../types' import type { ChatConfig } from '../../types'
import type { AppData, AppMeta, ConversationItem } from '@/models/share' 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 { 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 useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { defaultSystemFeatures } from '@/types/feature'
import { useEmbeddedChatbot } from '../hooks' import { useEmbeddedChatbot } from '../hooks'
import EmbeddedChatbot from '../index' import EmbeddedChatbot from '../index'
let mockBrandingWorkspaceLogo = ''
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: {
branding: { enabled: true, workspace_logo: mockBrandingWorkspaceLogo },
},
})
vi.mock('../hooks', () => ({ vi.mock('../hooks', () => ({
useEmbeddedChatbot: vi.fn(), useEmbeddedChatbot: vi.fn(),
})) }))
@ -26,10 +32,6 @@ vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(), default: vi.fn(),
})) }))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('../chat-wrapper', () => ({ vi.mock('../chat-wrapper', () => ({
__esModule: true, __esModule: true,
default: () => <div>chat area</div>, default: () => <div>chat area</div>,
@ -125,19 +127,9 @@ const createHookReturn = (overrides: Partial<EmbeddedChatbotHookReturn> = {}): E
describe('EmbeddedChatbot index', () => { describe('EmbeddedChatbot index', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockBrandingWorkspaceLogo = ''
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile) vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn()) 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', () => { describe('Loading and chat content', () => {
@ -159,17 +151,7 @@ describe('EmbeddedChatbot index', () => {
describe('Powered by branding', () => { describe('Powered by branding', () => {
it('should show workspace logo on mobile when branding is enabled', () => { it('should show workspace logo on mobile when branding is enabled', () => {
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({ mockBrandingWorkspaceLogo = 'https://example.com/workspace-logo.png'
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
},
},
setSystemFeatures: vi.fn(),
}))
render(<EmbeddedChatbot />) render(<EmbeddedChatbot />)

View File

@ -1,30 +1,25 @@
import type { ReactElement } from 'react'
import type { EmbeddedChatbotContextValue } from '../../context' import type { EmbeddedChatbotContextValue } from '../../context'
import type { AppData } from '@/models/share' import type { AppData } from '@/models/share'
import type { SystemFeatures } from '@/types/feature' import { act, screen, waitFor } from '@testing-library/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { useGlobalPublicStore } from '@/context/global-public-context' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { InstallationScope, LicenseStatus } from '@/types/feature'
import { useEmbeddedChatbotContext } from '../../context' import { useEmbeddedChatbotContext } from '../../context'
import Header from '../index' import Header from '../index'
let mockBranding = { enabled: true, workspace_logo: '' }
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: { branding: { ...mockBranding } },
})
vi.mock('../../context', () => ({ vi.mock('../../context', () => ({
useEmbeddedChatbotContext: vi.fn(), 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', () => ({ vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown', () => ({
default: () => <div data-testid="view-form-dropdown" />, default: () => <div data-testid="view-form-dropdown" />,
})) }))
type GlobalPublicStoreMock = {
systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void
}
describe('EmbeddedChatbot Header', () => { describe('EmbeddedChatbot Header', () => {
const defaultAppData: AppData = { const defaultAppData: AppData = {
app_id: 'test-app-id', app_id: 'test-app-id',
@ -47,48 +42,6 @@ describe('EmbeddedChatbot Header', () => {
allInputsHidden: false, 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 setupIframe = () => {
const mockPostMessage = vi.fn() const mockPostMessage = vi.fn()
const mockTop = { postMessage: mockPostMessage } const mockTop = { postMessage: mockPostMessage }
@ -100,11 +53,8 @@ describe('EmbeddedChatbot Header', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockBranding = { enabled: true, workspace_logo: '' }
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(defaultContext as EmbeddedChatbotContextValue) 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, 'self', { value: window, configurable: true })
Object.defineProperty(window, 'top', { 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', () => { it('should render workspace logo when branding is enabled and logo exists', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({ mockBranding = { enabled: true, workspace_logo: 'https://example.com/workspace.png' }
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
workspace_logo: 'https://example.com/workspace.png',
},
},
setSystemFeatures: vi.fn(),
}))
render(<Header title="Test Chatbot" />) render(<Header title="Test Chatbot" />)
@ -167,32 +108,13 @@ describe('EmbeddedChatbot Header', () => {
}) })
it('should render Dify logo by default when branding enabled is true but no logo provided', () => { 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({ mockBranding = { enabled: true, workspace_logo: '' }
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: '',
},
},
setSystemFeatures: vi.fn(),
}))
render(<Header title="Test Chatbot" />) render(<Header title="Test Chatbot" />)
expect(screen.getByAltText('Dify logo')).toBeInTheDocument() expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
}) })
it('should render Dify logo when branding is disabled', () => { it('should render Dify logo when branding is disabled', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({ mockBranding = { enabled: false, workspace_logo: '' }
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: false,
},
},
setSystemFeatures: vi.fn(),
}))
render(<Header title="Test Chatbot" />) render(<Header title="Test Chatbot" />)
expect(screen.getByAltText('Dify logo')).toBeInTheDocument() expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
}) })

View File

@ -1,6 +1,7 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { Theme } from '../theme/theme-context' import type { Theme } from '../theme/theme-context'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 Divider from '@/app/components/base/divider'
import DifyLogo from '@/app/components/base/logo/dify-logo' import DifyLogo from '@/app/components/base/logo/dify-logo'
import Tooltip from '@/app/components/base/tooltip' 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 { isClient } from '@/utils/client'
import { import {
useEmbeddedChatbotContext, useEmbeddedChatbotContext,
@ -44,7 +45,7 @@ const Header: FC<IHeaderProps> = ({
const [parentOrigin, setParentOrigin] = useState('') const [parentOrigin, setParentOrigin] = useState('')
const [showToggleExpandButton, setShowToggleExpandButton] = useState(false) const [showToggleExpandButton, setShowToggleExpandButton] = useState(false)
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const handleMessageReceived = useCallback((event: MessageEvent) => { const handleMessageReceived = useCallback((event: MessageEvent) => {
let currentParentOrigin = parentOrigin let currentParentOrigin = parentOrigin

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import type { AppData } from '@/models/share' import type { AppData } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { import {
useEffect, useEffect,
} from 'react' } from 'react'
@ -10,10 +11,10 @@ import Header from '@/app/components/base/chat/embedded-chatbot/header'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import DifyLogo from '@/app/components/base/logo/dify-logo' import DifyLogo from '@/app/components/base/logo/dify-logo'
import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header' 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 useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { AppSourceType } from '@/service/share' import { AppSourceType } from '@/service/share'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { import {
EmbeddedChatbotContext, EmbeddedChatbotContext,
useEmbeddedChatbotContext, useEmbeddedChatbotContext,
@ -34,7 +35,7 @@ const Chatbot = () => {
themeBuilder, themeBuilder,
} = useEmbeddedChatbotContext() } = useEmbeddedChatbotContext()
const { t } = useTranslation() const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const customConfig = appData?.custom_config const customConfig = appData?.custom_config
const site = appData?.site const site = appData?.site

View File

@ -157,18 +157,6 @@ describe('NewFeaturePanel', () => {
expect(screen.queryByText(/feature\.fileUpload\.title/)).not.toBeInTheDocument() expect(screen.queryByText(/feature\.fileUpload\.title/)).not.toBeInTheDocument()
expect(screen.queryByText(/feature\.imageUpload\.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', () => { describe('MoreLikeThis Feature', () => {
@ -204,12 +192,4 @@ describe('NewFeaturePanel', () => {
expect(screen.queryByText(/feature\.annotation\.title/)).not.toBeInTheDocument() expect(screen.queryByText(/feature\.annotation\.title/)).not.toBeInTheDocument()
}) })
}) })
describe('Edge Cases', () => {
it('should not show file upload tip when showFileUpload is false', () => {
renderPanel({ isChatMode: true, showFileUpload: false })
expect(screen.queryByText(/common\.fileUploadTip/)).not.toBeInTheDocument()
})
})
}) })

View File

@ -186,7 +186,7 @@ describe('OpeningSettingModal', () => {
expect(onCancel).toHaveBeenCalledTimes(1) 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() const onCancel = vi.fn()
await render( await render(
<OpeningSettingModal <OpeningSettingModal
@ -200,7 +200,7 @@ describe('OpeningSettingModal', () => {
closeButton.focus() closeButton.focus()
fireEvent.keyDown(closeButton, { key: 'Escape' }) 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 () => { it('should call onSave with updated data when save is clicked', async () => {
@ -257,6 +257,26 @@ describe('OpeningSettingModal', () => {
expect(allInputs.length).toBeGreaterThanOrEqual(1) expect(allInputs.length).toBeGreaterThanOrEqual(1)
}) })
it('should focus a new suggested question without destructive styling', async () => {
await render(
<OpeningSettingModal
data={defaultData}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
await userEvent.click(screen.getByText(/variableConfig\.addOption/))
const newInput = screen.getAllByPlaceholderText('appDebug.openingStatement.openingQuestionPlaceholder')
.find(input => (input as HTMLInputElement).value === '') as HTMLInputElement
const questionRow = newInput.parentElement
expect(newInput).toHaveFocus()
expect(questionRow).not.toHaveClass('border-components-input-border-destructive')
expect(questionRow).toHaveClass('border-components-input-border-active')
})
it('should delete a suggested question via save verification', async () => { it('should delete a suggested question via save verification', async () => {
const onSave = vi.fn() const onSave = vi.fn()
await render( await render(
@ -334,7 +354,39 @@ describe('OpeningSettingModal', () => {
) )
// Count is displayed as "2/10" across child elements // Count is displayed as "2/10" across child elements
expect(screen.getByText(/openingStatement\.openingQuestion/)).toBeInTheDocument() expect(screen.getByText('appDebug.openingStatement.openingQuestion')).toBeInTheDocument()
})
it('should render separate opener and question sections', async () => {
await render(
<OpeningSettingModal
data={defaultData}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
expect(screen.getByTestId('opener-input-section')).toBeInTheDocument()
expect(screen.getByTestId('opener-questions-section')).toBeInTheDocument()
expect(screen.getByText(/openingStatement\.editorTitle/)).toBeInTheDocument()
expect(screen.getByTestId('opening-questions-tooltip')).toBeInTheDocument()
expect(screen.queryByText(/openingStatement\.openingQuestionDescription/)).not.toBeInTheDocument()
})
it('should show the opening questions description in a tooltip', async () => {
await render(
<OpeningSettingModal
data={defaultData}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
act(() => {
fireEvent.mouseEnter(screen.getByTestId('opening-questions-tooltip'))
})
expect(screen.getByText(/openingStatement\.openingQuestionDescription/)).toBeInTheDocument()
}) })
it('should call onAutoAddPromptVariable when confirm add is clicked', async () => { it('should call onAutoAddPromptVariable when confirm add is clicked', async () => {
@ -540,7 +592,9 @@ describe('OpeningSettingModal', () => {
const editor = getPromptEditor() const editor = getPromptEditor()
expect(editor.textContent?.trim()).toBe('') 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 () => { it('should render with empty suggested questions when field is missing', async () => {

View File

@ -102,7 +102,7 @@ const ConversationOpener = ({
<> <>
{!isHovering && ( {!isHovering && (
<div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"> <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">
{opening.opening_statement || t('openingStatement.placeholder', { ns: 'appDebug' })} {opening.opening_statement || t('openingStatement.placeholderLine1', { ns: 'appDebug' })}
</div> </div>
)} )}
{isHovering && ( {isHovering && (

View File

@ -3,8 +3,9 @@ import type { InputVar } from '@/app/components/workflow/types'
import type { PromptVariable } from '@/models/debug' import type { PromptVariable } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' 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 { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer' import { produce } from 'immer'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } 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 ConfirmAddVar from '@/app/components/app/configuration/config-prompt/confirm-add-var'
import { getInputKeys } from '@/app/components/base/block-input' import { getInputKeys } from '@/app/components/base/block-input'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import Modal from '@/app/components/base/modal'
import PromptEditor from '@/app/components/base/prompt-editor' import PromptEditor from '@/app/components/base/prompt-editor'
import { checkKeys, getNewVar } from '@/utils/var' import { checkKeys, getNewVar } from '@/utils/var'
@ -39,6 +39,7 @@ const OpeningSettingModal = ({
const { t } = useTranslation() const { t } = useTranslation()
const [tempValue, setTempValue] = useState(data?.opening_statement || '') const [tempValue, setTempValue] = useState(data?.opening_statement || '')
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react/set-state-in-effect
setTempValue(data.opening_statement || '') setTempValue(data.opening_statement || '')
}, [data.opening_statement]) }, [data.opening_statement])
const [tempSuggestedQuestions, setTempSuggestedQuestions] = useState(data.suggested_questions || []) const [tempSuggestedQuestions, setTempSuggestedQuestions] = useState(data.suggested_questions || [])
@ -99,22 +100,49 @@ const OpeningSettingModal = ({
const [focusID, setFocusID] = useState<number | null>(null) const [focusID, setFocusID] = useState<number | null>(null)
const [deletingID, setDeletingID] = useState<number | null>(null) const [deletingID, setDeletingID] = useState<number | null>(null)
const [autoFocusQuestionID, setAutoFocusQuestionID] = useState<number | null>(null)
const openerPlaceholder = (
<span className="block break-words whitespace-pre-wrap">
{t('openingStatement.placeholderLine1', { ns: 'appDebug' })}
<br />
{t('openingStatement.placeholderLine2', { ns: 'appDebug' })}
</span>
)
const renderQuestions = () => { const renderQuestions = () => {
return ( return (
<div> <div>
<div className="flex items-center py-2"> <div className="mb-3 flex items-center justify-between">
<div className="flex shrink-0 space-x-0.5 text-xs leading-[18px] font-medium text-text-tertiary"> <div className="flex items-center gap-1">
<div className="uppercase">{t('openingStatement.openingQuestion', { ns: 'appDebug' })}</div> <div className="text-sm font-medium text-text-primary">
<div>·</div> {t('openingStatement.openingQuestion', { ns: 'appDebug' })}
<div>
{tempSuggestedQuestions.length}
/
{MAX_QUESTION_NUM}
</div> </div>
<Tooltip>
<TooltipTrigger
delay={0}
render={(
<button
type="button"
className="flex items-center rounded-sm p-px text-text-quaternary hover:text-text-tertiary"
data-testid="opening-questions-tooltip"
aria-label={t('openingStatement.openingQuestionDescription', { ns: 'appDebug' })}
>
<span className="i-ri-question-line h-3.5 w-3.5" />
</button>
)}
/>
<TooltipContent className="max-w-[220px] system-sm-regular text-text-secondary">
{t('openingStatement.openingQuestionDescription', { ns: 'appDebug' })}
</TooltipContent>
</Tooltip>
</div>
<div className="text-xs leading-[18px] font-medium text-text-tertiary">
{tempSuggestedQuestions.length}
/
{MAX_QUESTION_NUM}
</div> </div>
<Divider bgStyle="gradient" className="ml-3 h-px w-0 grow" />
</div> </div>
<Divider bgStyle="gradient" className="mb-3 h-px" />
<ReactSortable <ReactSortable
className="space-y-1" className="space-y-1"
list={tempSuggestedQuestions.map((name, index) => { list={tempSuggestedQuestions.map((name, index) => {
@ -133,8 +161,8 @@ const OpeningSettingModal = ({
<div <div
className={cn( className={cn(
'group relative flex items-center rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 hover:bg-components-panel-on-panel-item-bg-hover', 'group relative flex items-center rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 hover:bg-components-panel-on-panel-item-bg-hover',
focusID === index && 'border-components-input-border-active bg-components-input-bg-active hover:border-components-input-border-active hover:bg-components-input-bg-active',
deletingID === index && 'border-components-input-border-destructive bg-state-destructive-hover hover:border-components-input-border-destructive hover:bg-state-destructive-hover', deletingID === index && 'border-components-input-border-destructive bg-state-destructive-hover hover:border-components-input-border-destructive hover:bg-state-destructive-hover',
focusID === index && 'border-components-input-border-active bg-components-input-bg-active hover:border-components-input-border-active hover:bg-components-input-bg-active',
)} )}
key={index} key={index}
> >
@ -152,8 +180,13 @@ const OpeningSettingModal = ({
return item 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" 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)} onBlur={() => setFocusID(null)}
/> />
@ -173,7 +206,12 @@ const OpeningSettingModal = ({
</ReactSortable> </ReactSortable>
{tempSuggestedQuestions.length < MAX_QUESTION_NUM && ( {tempSuggestedQuestions.length < MAX_QUESTION_NUM && (
<div <div
onClick={() => { 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" 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"
> >
<span className="i-ri-add-line h-4 w-4" /> <span className="i-ri-add-line h-4 w-4" />
@ -185,81 +223,90 @@ const OpeningSettingModal = ({
} }
return ( return (
<Modal <Dialog open onOpenChange={open => !open && onCancel()} disablePointerDismissal>
isShow <DialogContent className="mt-14 w-[640px] max-w-none rounded-2xl bg-components-panel-bg-blur p-6">
onClose={noop} <div className="mb-6 flex items-center justify-between">
className="mt-14! w-[640px]! max-w-none! bg-components-panel-bg-blur! p-6!" <div className="title-2xl-semi-bold text-text-primary">{t('feature.conversationOpener.title', { ns: 'appDebug' })}</div>
> <div
<div className="mb-6 flex items-center justify-between"> className="cursor-pointer p-1"
<div className="title-2xl-semi-bold text-text-primary">{t('feature.conversationOpener.title', { ns: 'appDebug' })}</div> onClick={onCancel}
<div data-testid="close-modal"
className="cursor-pointer p-1" role="button"
onClick={onCancel} tabIndex={0}
data-testid="close-modal" onKeyDown={(e) => {
role="button" if (e.key === 'Enter' || e.key === ' ') {
tabIndex={0} e.preventDefault()
onKeyDown={(e) => { onCancel()
if (e.key === 'Enter' || e.key === ' ') { }
e.preventDefault()
onCancel()
}
}}
>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</div>
</div>
<div className="mb-8 flex gap-2">
<div className="mt-1.5 h-8 w-8 shrink-0 rounded-lg border-components-panel-border bg-util-colors-orange-dark-orange-dark-500 p-1.5">
<span className="i-ri-asterisk h-5 w-5 text-text-primary-on-surface" />
</div>
<div className="grow rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg p-3 shadow-xs">
<PromptEditor
value={tempValue}
onChange={setTempValue}
placeholder={t('openingStatement.placeholder', { ns: 'appDebug' }) as string}
variableBlock={{
show: true,
variables: [
// Prompt variables
...promptVariables.map(item => ({
name: item.name || item.key,
value: item.key,
})),
// Workflow variables
...workflowVariables.map(item => ({
name: item.variable,
value: item.variable,
})),
],
}} }}
/> >
{renderQuestions()} <span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</div>
</div> </div>
</div> <div className="mb-8 space-y-4">
<div className="flex items-center justify-end"> <div
<Button data-testid="opener-input-section"
onClick={onCancel} className="py-2"
className="mr-2" >
> <div className="mb-3 text-sm font-medium text-text-primary">
{t('operation.cancel', { ns: 'common' })} {t('openingStatement.editorTitle', { ns: 'appDebug' })}
</Button> </div>
<Button <div className="relative min-h-[80px] rounded-lg bg-components-input-bg-normal px-3 py-2">
variant="primary" <PromptEditor
onClick={() => handleSave()} value={tempValue}
disabled={isSaveDisabled} onChange={setTempValue}
> placeholder={openerPlaceholder}
{t('operation.save', { ns: 'common' })} placeholderClassName="!overflow-visible !whitespace-pre-wrap !text-clip break-words pr-8"
</Button> variableBlock={{
</div> show: true,
{isShowConfirmAddVar && ( variables: [
<ConfirmAddVar // Prompt variables
varNameArr={notIncludeKeys} ...promptVariables.map(item => ({
onConfirm={autoAddVar} name: item.name || item.key,
onCancel={cancelAutoAddVar} value: item.key,
onHide={hideConfirmAddVar} })),
/> // Workflow variables
)} ...workflowVariables.map(item => ({
</Modal> name: item.variable,
value: item.variable,
})),
],
}}
/>
</div>
</div>
<div
data-testid="opener-questions-section"
className="py-2"
>
{renderQuestions()}
</div>
</div>
<div className="flex items-center justify-end">
<Button
onClick={onCancel}
className="mr-2"
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={() => handleSave()}
disabled={isSaveDisabled}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
{isShowConfirmAddVar && (
<ConfirmAddVar
varNameArr={notIncludeKeys}
onConfirm={autoAddVar}
onCancel={cancelAutoAddVar}
onHide={hideConfirmAddVar}
/>
)}
</DialogContent>
</Dialog>
) )
} }

View File

@ -1,7 +1,7 @@
import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { OnFeaturesChange } from '@/app/components/base/features/types'
import type { InputVar } from '@/app/components/workflow/types' import type { InputVar } from '@/app/components/workflow/types'
import type { PromptVariable } from '@/models/debug' import type { PromptVariable } from '@/models/debug'
import { RiCloseLine, RiInformation2Fill } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply' import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply'
@ -64,19 +64,6 @@ const NewFeaturePanel = ({
</div> </div>
{/* list */} {/* list */}
<div className="grow basis-0 overflow-y-auto px-4 pb-4"> <div className="grow basis-0 overflow-y-auto px-4 pb-4">
{showFileUpload && (
<div className="relative mb-1 rounded-xl border border-components-panel-border p-2 shadow-xs">
<div className="absolute top-0 left-0 h-full w-full rounded-xl opacity-40" style={{ background: 'linear-gradient(92deg, rgba(11, 165, 236, 0.25) 18.12%, rgba(255, 255, 255, 0.00) 167.31%)' }}></div>
<div className="relative flex h-full w-full items-start">
<div className="mr-0.5 shrink-0 p-0.5">
<RiInformation2Fill className="h-5 w-5 text-text-accent" />
</div>
<div className="p-1 system-xs-medium text-text-primary">
<span>{isChatMode ? t('common.fileUploadTip', { ns: 'workflow' }) : t('common.ImageUploadLegacyTip', { ns: 'workflow' })}</span>
</div>
</div>
</div>
)}
{!isChatMode && !inWorkflow && ( {!isChatMode && !inWorkflow && (
<MoreLikeThis disabled={disabled} onChange={onChange} /> <MoreLikeThis disabled={disabled} onChange={onChange} />
)} )}

View File

@ -1,9 +1,10 @@
import type { ReactElement } from 'react'
import type { AppContextValue } from '@/context/app-context' import type { AppContextValue } from '@/context/app-context'
import type { SystemFeatures } from '@/types/feature' import { screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config' import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { import {
@ -12,12 +13,19 @@ import {
useAppContext, useAppContext,
userProfilePlaceholder, userProfilePlaceholder,
} from '@/context/app-context' } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { defaultSystemFeatures } from '@/types/feature'
import CustomPage from '../index' 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 } = vi.hoisted(() => {
const mockToast = Object.assign(vi.fn(), { const mockToast = Object.assign(vi.fn(), {
success: vi.fn(), success: vi.fn(),
@ -44,9 +52,6 @@ vi.mock('@/context/app-context', async (importOriginal) => {
useAppContext: vi.fn(), useAppContext: vi.fn(),
} }
}) })
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({ vi.mock('@langgenius/dify-ui/toast', () => ({
toast: mockToast, toast: mockToast,
})) }))
@ -54,7 +59,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
const mockUseProviderContext = vi.mocked(useProviderContext) const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseModalContext = vi.mocked(useModalContext) const mockUseModalContext = vi.mocked(useModalContext)
const mockUseAppContext = vi.mocked(useAppContext) const mockUseAppContext = vi.mocked(useAppContext)
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const createProviderContext = ({ const createProviderContext = ({
enableBilling = false, enableBilling = false,
@ -93,15 +97,6 @@ const createAppContextValue = (): AppContextValue => ({
isValidatingCurrentWorkspace: false, isValidatingCurrentWorkspace: false,
}) })
const createSystemFeatures = (): SystemFeatures => ({
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
},
})
describe('CustomPage', () => { describe('CustomPage', () => {
const setShowPricingModal = vi.fn() const setShowPricingModal = vi.fn()
@ -113,10 +108,6 @@ describe('CustomPage', () => {
setShowPricingModal, setShowPricingModal,
} as unknown as ReturnType<typeof useModalContext>) } as unknown as ReturnType<typeof useModalContext>)
mockUseAppContext.mockReturnValue(createAppContextValue()) mockUseAppContext.mockReturnValue(createAppContextValue())
mockUseGlobalPublicStore.mockImplementation(selector => selector({
systemFeatures: createSystemFeatures(),
setSystemFeatures: vi.fn(),
}))
}) })
// Integration coverage for the page and its child custom brand section. // Integration coverage for the page and its child custom brand section.

View File

@ -1,9 +1,10 @@
import type { ChangeEvent } from 'react' import type { ChangeEvent } from 'react'
import type { AppContextValue } from '@/context/app-context' import type { AppContextValue } from '@/context/app-context'
import type { SystemFeatures } from '@/types/feature' 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context' 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 { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
import { defaultPlan } from '@/app/components/billing/config' import { defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
@ -13,12 +14,22 @@ import {
useAppContext, useAppContext,
userProfilePlaceholder, userProfilePlaceholder,
} from '@/context/app-context' } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { updateCurrentWorkspace } from '@/service/common' import { updateCurrentWorkspace } from '@/service/common'
import { defaultSystemFeatures } from '@/types/feature'
import useWebAppBrand from '../use-web-app-brand' import useWebAppBrand from '../use-web-app-brand'
let currentBrandingOverrides: Partial<SystemFeatures['branding']> = {}
const renderHook = <Result, Props = void>(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, mockToast } = vi.hoisted(() => {
const mockNotify = vi.fn() const mockNotify = vi.fn()
const mockToast = Object.assign(mockNotify, { const mockToast = Object.assign(mockNotify, {
@ -49,9 +60,6 @@ vi.mock('@/context/app-context', async (importOriginal) => {
vi.mock('@/context/provider-context', () => ({ vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(), useProviderContext: vi.fn(),
})) }))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/app/components/base/image-uploader/utils', () => ({ vi.mock('@/app/components/base/image-uploader/utils', () => ({
imageUpload: vi.fn(), imageUpload: vi.fn(),
getImageUploadErrorMessage: vi.fn(), getImageUploadErrorMessage: vi.fn(),
@ -60,7 +68,6 @@ vi.mock('@/app/components/base/image-uploader/utils', () => ({
const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace) const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace)
const mockUseAppContext = vi.mocked(useAppContext) const mockUseAppContext = vi.mocked(useAppContext)
const mockUseProviderContext = vi.mocked(useProviderContext) const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockImageUpload = vi.mocked(imageUpload) const mockImageUpload = vi.mocked(imageUpload)
const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage) const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage)
@ -80,16 +87,6 @@ const createProviderContext = ({
}) })
} }
const createSystemFeatures = (brandingOverrides: Partial<SystemFeatures['branding']> = {}): SystemFeatures => ({
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
...brandingOverrides,
},
})
const createAppContextValue = (overrides: Partial<AppContextValue> = {}): AppContextValue => { const createAppContextValue = (overrides: Partial<AppContextValue> = {}): AppContextValue => {
const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides
const workspaceOverrides: Partial<AppContextValue['currentWorkspace']> = currentWorkspaceOverride ?? {} const workspaceOverrides: Partial<AppContextValue['currentWorkspace']> = currentWorkspaceOverride ?? {}
@ -122,21 +119,16 @@ const createAppContextValue = (overrides: Partial<AppContextValue> = {}): AppCon
describe('useWebAppBrand', () => { describe('useWebAppBrand', () => {
let appContextValue: AppContextValue let appContextValue: AppContextValue
let systemFeatures: SystemFeatures
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
appContextValue = createAppContextValue() appContextValue = createAppContextValue()
systemFeatures = createSystemFeatures() currentBrandingOverrides = {}
mockUpdateCurrentWorkspace.mockResolvedValue(appContextValue.currentWorkspace) mockUpdateCurrentWorkspace.mockResolvedValue(appContextValue.currentWorkspace)
mockUseAppContext.mockImplementation(() => appContextValue) mockUseAppContext.mockImplementation(() => appContextValue)
mockUseProviderContext.mockReturnValue(createProviderContext()) mockUseProviderContext.mockReturnValue(createProviderContext())
mockUseGlobalPublicStore.mockImplementation(selector => selector({
systemFeatures,
setSystemFeatures: vi.fn(),
}))
mockGetImageUploadErrorMessage.mockReturnValue('upload error') mockGetImageUploadErrorMessage.mockReturnValue('upload error')
}) })
@ -174,10 +166,7 @@ describe('useWebAppBrand', () => {
}) })
it('should fall back to an empty workspace logo when branding is disabled', () => { it('should fall back to an empty workspace logo when branding is disabled', () => {
systemFeatures = createSystemFeatures({ currentBrandingOverrides = { enabled: false, workspace_logo: '' }
enabled: false,
workspace_logo: '',
})
const { result } = renderHook(() => useWebAppBrand()) const { result } = renderHook(() => useWebAppBrand())

View File

@ -1,13 +1,14 @@
import type { ChangeEvent } from 'react' import type { ChangeEvent } from 'react'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { updateCurrentWorkspace } from '@/service/common' import { updateCurrentWorkspace } from '@/service/common'
import { systemFeaturesQueryOptions } from '@/service/system-features'
const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024 const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024
const CUSTOM_CONFIG_URL = '/workspaces/custom-config' const CUSTOM_CONFIG_URL = '/workspaces/custom-config'
@ -19,7 +20,7 @@ const useWebAppBrand = () => {
const [fileId, setFileId] = useState('') const [fileId, setFileId] = useState('')
const [imgKey, setImgKey] = useState(() => Date.now()) const [imgKey, setImgKey] = useState(() => Date.now())
const [uploadProgress, setUploadProgress] = useState(0) const [uploadProgress, setUploadProgress] = useState(0)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isSandbox = enableBilling && plan.type === Plan.sandbox const isSandbox = enableBilling && plan.type === Plan.sandbox
const uploading = uploadProgress > 0 && uploadProgress < 100 const uploading = uploadProgress > 0 && uploadProgress < 100
const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || '' const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || ''

View File

@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import BuiltInPipelineList from '../built-in-pipeline-list' import BuiltInPipelineList from '../built-in-pipeline-list'
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: { enable_marketplace: true },
})
vi.mock('../create-card', () => ({ vi.mock('../create-card', () => ({
default: () => <div data-testid="create-card">CreateCard</div>, default: () => <div data-testid="create-card">CreateCard</div>,
})) }))
@ -22,13 +28,6 @@ vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale, 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() const mockUsePipelineTemplateList = vi.fn()
vi.mock('@/service/use-pipeline', () => ({ vi.mock('@/service/use-pipeline', () => ({
usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args), usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args),

View File

@ -1,7 +1,8 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useLocale } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { usePipelineTemplateList } from '@/service/use-pipeline' import { usePipelineTemplateList } from '@/service/use-pipeline'
import CreateCard from './create-card' import CreateCard from './create-card'
import TemplateCard from './template-card' import TemplateCard from './template-card'
@ -13,7 +14,10 @@ const BuiltInPipelineList = () => {
return locale return locale
return LanguagesSupported[0] return LanguagesSupported[0]
}, [locale]) }, [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 { data: pipelineList, isLoading } = usePipelineTemplateList({ type: 'built-in', language }, enableMarketplace)
const list = pipelineList?.pipeline_templates || [] const list = pipelineList?.pipeline_templates || []

View File

@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import List from '../index' import List from '../index'
let mockBrandingEnabled = false
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: { branding: { enabled: mockBrandingEnabled } },
})
const mockPush = vi.fn() const mockPush = vi.fn()
const mockReplace = vi.fn() const mockReplace = vi.fn()
vi.mock('@/next/navigation', () => ({ vi.mock('@/next/navigation', () => ({
@ -20,15 +27,6 @@ vi.mock('@/context/app-context', () => ({
useSelector: () => true, useSelector: () => true,
})) }))
// Mock global public context
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
systemFeatures: {
branding: { enabled: false },
},
}),
}))
// Mock external api panel context // Mock external api panel context
const mockSetShowExternalApiPanel = vi.fn() const mockSetShowExternalApiPanel = vi.fn()
vi.mock('@/context/external-api-panel-context', () => ({ vi.mock('@/context/external-api-panel-context', () => ({
@ -133,6 +131,7 @@ vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', ()
describe('List', () => { describe('List', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockBrandingEnabled = false
}) })
describe('Rendering', () => { describe('Rendering', () => {
@ -319,18 +318,9 @@ describe('List', () => {
}) })
it('should not show DatasetFooter when branding is enabled', async () => { it('should not show DatasetFooter when branding is enabled', async () => {
vi.doMock('@/context/global-public-context', () => ({ mockBrandingEnabled = true
useGlobalPublicStore: () => ({
systemFeatures: {
branding: { enabled: true },
},
}),
}))
vi.resetModules() render(<List />)
const { default: ListComponent } = await import('../index')
render(<ListComponent />)
expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument() expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument()
}) })

View File

@ -1,10 +1,11 @@
'use client' 'use client'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean, useDebounceFn } from 'ahooks' import { useBoolean, useDebounceFn } from 'ahooks'
// Libraries // Libraries
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import TagManagementModal from '@/app/components/base/tag-management' 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 CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context' import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
import { useExternalApiPanel } from '@/context/external-api-panel-context' import { useExternalApiPanel } from '@/context/external-api-panel-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset' import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
import { systemFeaturesQueryOptions } from '@/service/system-features'
// Components // Components
import ExternalAPIPanel from '../external-api/external-api-panel' import ExternalAPIPanel from '../external-api/external-api-panel'
import ServiceApi from '../extra-info/service-api' import ServiceApi from '../extra-info/service-api'
@ -25,7 +26,7 @@ import Datasets from './datasets'
const List = () => { const List = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceOwner } = useAppContext() const { isCurrentWorkspaceOwner } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel() const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel()

View File

@ -9,7 +9,7 @@ export function ReactScanLoader() {
<Script <Script
src="//unpkg.com/react-scan/dist/auto.global.js" src="//unpkg.com/react-scan/dist/auto.global.js"
crossOrigin="anonymous" crossOrigin="anonymous"
strategy="afterInteractive" strategy="beforeInteractive"
/> />
) )
} }

View File

@ -1,7 +1,8 @@
import type { AppCardProps } from '../index' import type { AppCardProps } from '../index'
import type { App } from '@/models/explore' import type { App } from '@/models/explore'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, screen } from '@testing-library/react'
import * as React from 'react' import * as React from 'react'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import { trackEvent } from '@/app/components/base/amplitude' import { trackEvent } from '@/app/components/base/amplitude'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import AppCard from '../index' import AppCard from '../index'

View File

@ -5,10 +5,11 @@ import { PlusIcon } from '@heroicons/react/20/solid'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { RiInformation2Line } from '@remixicon/react' import { RiInformation2Line } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude' import { trackEvent } from '@/app/components/base/amplitude'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
import { useGlobalPublicStore } from '@/context/global-public-context' import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { AppTypeIcon } from '../../app/type-selector' import { AppTypeIcon } from '../../app/type-selector'
@ -29,7 +30,7 @@ const AppCard = ({
}: AppCardProps) => { }: AppCardProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { app: appBasicInfo } = app const { app: appBasicInfo } = app
const { systemFeatures } = useGlobalPublicStore() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
const handleTryApp = () => { const handleTryApp = () => {
trackEvent('preview_template', { trackEvent('preview_template', {

View File

@ -1,9 +1,10 @@
import type { ReactNode } from 'react'
import type { Mock } from 'vitest' import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/models/explore' import type { App } from '@/models/explore'
import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { fetchAppDetail } from '@/service/explore' import { fetchAppDetail } from '@/service/explore'
import { useMembers } from '@/service/use-common' import { useMembers } from '@/service/use-common'
import { renderWithNuqs } from '@/test/nuqs-testing' import { renderWithNuqs } from '@/test/nuqs-testing'
@ -134,12 +135,28 @@ const mockMemberRole = (hasEditPermission: boolean) => {
}) })
} }
const renderAppList = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record<string, string>) => { type RenderOptions = {
enableExploreBanner?: boolean
}
const renderAppList = (
hasEditPermission = false,
onSuccess?: () => void,
searchParams?: Record<string, string>,
options: RenderOptions = {},
) => {
mockMemberRole(hasEditPermission) mockMemberRole(hasEditPermission)
return renderWithNuqs( const { wrapper: SystemFeaturesWrapper, queryClient } = createSystemFeaturesWrapper({
<AppList onSuccess={onSuccess} />, systemFeatures: { enable_explore_banner: options.enableExploreBanner ?? false },
})
const Wrapped = ({ children }: { children: ReactNode }) => (
<SystemFeaturesWrapper>{children}</SystemFeaturesWrapper>
)
const rendered = renderWithNuqs(
<Wrapped><AppList onSuccess={onSuccess} /></Wrapped>,
{ searchParams }, { searchParams },
) )
return { ...rendered, queryClient }
} }
describe('AppList', () => { describe('AppList', () => {
@ -435,18 +452,12 @@ describe('AppList', () => {
describe('Banner', () => { describe('Banner', () => {
it('should render banner when enable_explore_banner is true', () => { it('should render banner when enable_explore_banner is true', () => {
useGlobalPublicStore.setState({
systemFeatures: {
...useGlobalPublicStore.getState().systemFeatures,
enable_explore_banner: true,
},
})
mockExploreData = { mockExploreData = {
categories: ['Writing'], categories: ['Writing'],
allList: [createApp()], allList: [createApp()],
} }
renderAppList() renderAppList(false, undefined, undefined, { enableExploreBanner: true })
expect(screen.getByTestId('explore-banner')).toBeInTheDocument() expect(screen.getByTestId('explore-banner')).toBeInTheDocument()
}) })

View File

@ -5,6 +5,7 @@ import type { App } from '@/models/explore'
import type { TryAppSelection } from '@/types/try-app' import type { TryAppSelection } from '@/types/try-app'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks' import { useDebounceFn } from 'ahooks'
import { useQueryState } from 'nuqs' import { useQueryState } from 'nuqs'
import * as React from 'react' import * as React from 'react'
@ -18,12 +19,12 @@ import Banner from '@/app/components/explore/banner/banner'
import Category from '@/app/components/explore/category' import Category from '@/app/components/explore/category'
import CreateAppModal from '@/app/components/explore/create-app-modal' import CreateAppModal from '@/app/components/explore/create-app-modal'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useImportDSL } from '@/hooks/use-import-dsl' import { useImportDSL } from '@/hooks/use-import-dsl'
import { import {
DSLImportMode, DSLImportMode,
} from '@/models/app' } from '@/models/app'
import { fetchAppDetail } from '@/service/explore' import { fetchAppDetail } from '@/service/explore'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useMembers } from '@/service/use-common' import { useMembers } from '@/service/use-common'
import { useExploreAppList } from '@/service/use-explore' import { useExploreAppList } from '@/service/use-explore'
import { trackCreateApp } from '@/utils/create-app-tracking' import { trackCreateApp } from '@/utils/create-app-tracking'
@ -39,7 +40,7 @@ const Apps = ({
}: AppsProps) => { }: AppsProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { userProfile } = useAppContext() const { userProfile } = useAppContext()
const { systemFeatures } = useGlobalPublicStore() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: membersData } = useMembers() const { data: membersData } = useMembers()
const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' }) const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' })
const userAccount = membersData?.accounts?.find(account => account.id === userProfile.id) const userAccount = membersData?.accounts?.find(account => account.id === userProfile.id)

View File

@ -1,6 +1,7 @@
import type { TryAppInfo } from '@/service/try-app' import type { TryAppInfo } from '@/service/try-app'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import TryApp from '../index' import TryApp from '../index'
import { TypeEnum } from '../tab' import { TypeEnum } from '../tab'

View File

@ -3,13 +3,14 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { App as AppType } from '@/models/explore' import type { App as AppType } from '@/models/explore'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react' import * as React from 'react'
import { useState } from 'react' import { useState } from 'react'
import AppUnavailable from '@/app/components/base/app-unavailable' import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal/index' import Modal from '@/app/components/base/modal/index'
import { IS_CLOUD_EDITION } from '@/config' import { IS_CLOUD_EDITION } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context' import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useGetTryAppInfo } from '@/service/use-try-app' import { useGetTryAppInfo } from '@/service/use-try-app'
import App from './app' import App from './app'
import AppInfo from './app-info' import AppInfo from './app-info'
@ -31,7 +32,7 @@ const TryApp: FC<Props> = ({
onClose, onClose,
onCreate, onCreate,
}) => { }) => {
const { systemFeatures } = useGlobalPublicStore() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app) const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app)
const canUseTryTab = IS_CLOUD_EDITION && (app ? isTrialApp : true) const canUseTryTab = IS_CLOUD_EDITION && (app ? isTrialApp : true)
const [type, setType] = useState<TypeEnum>(() => (canUseTryTab ? TypeEnum.TRY : TypeEnum.DETAIL)) const [type, setType] = useState<TypeEnum>(() => (canUseTryTab ? TypeEnum.TRY : TypeEnum.DETAIL))

View File

@ -1,5 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react' import type { ReactElement } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { vi } from 'vitest' import { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import Header from '../index' import Header from '../index'
function createMockComponent(testId: string) { function createMockComponent(testId: string) {
@ -93,21 +95,16 @@ vi.mock('@/context/modal-context', () => ({
}), }),
})) }))
vi.mock('@/context/global-public-context', () => { const renderHeader = (ui: ReactElement = <Header />) =>
type SystemFeatures = { branding: { enabled: boolean, application_title: string | null, workspace_logo: string | null } } renderWithSystemFeatures(ui, {
return { systemFeatures: {
useGlobalPublicStore: (selector: (s: { systemFeatures: SystemFeatures }) => SystemFeatures) => branding: {
selector({ enabled: mockBrandingEnabled,
systemFeatures: { application_title: mockBrandingTitle ?? '',
branding: { workspace_logo: mockBrandingLogo ?? '',
enabled: mockBrandingEnabled, },
application_title: mockBrandingTitle, },
workspace_logo: mockBrandingLogo, })
},
},
}),
}
})
describe('Header', () => { describe('Header', () => {
beforeEach(() => { beforeEach(() => {
@ -123,7 +120,7 @@ describe('Header', () => {
}) })
it('should render header with main nav components', () => { it('should render header with main nav components', () => {
render(<Header />) renderHeader()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument() expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.getByTestId('workplace-selector')).toBeInTheDocument() expect(screen.getByTestId('workplace-selector')).toBeInTheDocument()
@ -133,7 +130,7 @@ describe('Header', () => {
it('should show license nav when billing disabled, plan badge when enabled', () => { it('should show license nav when billing disabled, plan badge when enabled', () => {
mockEnableBilling = false mockEnableBilling = false
const { rerender } = render(<Header />) const { rerender } = renderHeader()
expect(screen.getByTestId('license-nav')).toBeInTheDocument() expect(screen.getByTestId('license-nav')).toBeInTheDocument()
expect(screen.queryByTestId('plan-badge')).not.toBeInTheDocument() expect(screen.queryByTestId('plan-badge')).not.toBeInTheDocument()
@ -145,7 +142,7 @@ describe('Header', () => {
it('should hide explore nav when user is dataset operator', () => { it('should hide explore nav when user is dataset operator', () => {
mockIsDatasetOperator = true mockIsDatasetOperator = true
render(<Header />) renderHeader()
expect(screen.queryByTestId('explore-nav')).not.toBeInTheDocument() expect(screen.queryByTestId('explore-nav')).not.toBeInTheDocument()
expect(screen.getByTestId('dataset-nav')).toBeInTheDocument() expect(screen.getByTestId('dataset-nav')).toBeInTheDocument()
@ -154,7 +151,7 @@ describe('Header', () => {
it('should call pricing modal for free plan, settings modal for paid plan', () => { it('should call pricing modal for free plan, settings modal for paid plan', () => {
mockEnableBilling = true mockEnableBilling = true
mockPlanType = 'sandbox' mockPlanType = 'sandbox'
const { rerender } = render(<Header />) const { rerender } = renderHeader()
fireEvent.click(screen.getByTestId('plan-badge')) fireEvent.click(screen.getByTestId('plan-badge'))
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
@ -167,7 +164,7 @@ describe('Header', () => {
it('should render mobile layout without env nav', () => { it('should render mobile layout without env nav', () => {
mockMedia = 'mobile' mockMedia = 'mobile'
render(<Header />) renderHeader()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument() expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.queryByTestId('env-nav')).not.toBeInTheDocument() expect(screen.queryByTestId('env-nav')).not.toBeInTheDocument()
@ -178,7 +175,7 @@ describe('Header', () => {
mockBrandingTitle = 'Acme Workspace' mockBrandingTitle = 'Acme Workspace'
mockBrandingLogo = '/logo.png' mockBrandingLogo = '/logo.png'
render(<Header />) renderHeader()
expect(screen.getByText('Acme Workspace')).toBeInTheDocument() expect(screen.getByText('Acme Workspace')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /logo/i })).toBeInTheDocument() expect(screen.getByRole('img', { name: /logo/i })).toBeInTheDocument()
@ -190,7 +187,7 @@ describe('Header', () => {
mockBrandingTitle = 'Custom Title' mockBrandingTitle = 'Custom Title'
mockBrandingLogo = null mockBrandingLogo = null
render(<Header />) renderHeader()
expect(screen.getByText('Custom Title')).toBeInTheDocument() expect(screen.getByText('Custom Title')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument() expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
@ -201,7 +198,7 @@ describe('Header', () => {
mockBrandingTitle = null mockBrandingTitle = null
mockBrandingLogo = null mockBrandingLogo = null
render(<Header />) renderHeader()
expect(screen.getByText('Dify')).toBeInTheDocument() expect(screen.getByText('Dify')).toBeInTheDocument()
}) })
@ -210,7 +207,7 @@ describe('Header', () => {
mockIsWorkspaceEditor = true mockIsWorkspaceEditor = true
mockIsDatasetOperator = false mockIsDatasetOperator = false
render(<Header />) renderHeader()
expect(screen.getByTestId('dataset-nav')).toBeInTheDocument() expect(screen.getByTestId('dataset-nav')).toBeInTheDocument()
expect(screen.getByTestId('explore-nav')).toBeInTheDocument() expect(screen.getByTestId('explore-nav')).toBeInTheDocument()
@ -221,7 +218,7 @@ describe('Header', () => {
mockIsWorkspaceEditor = false mockIsWorkspaceEditor = false
mockIsDatasetOperator = false mockIsDatasetOperator = false
render(<Header />) renderHeader()
expect(screen.queryByTestId('dataset-nav')).not.toBeInTheDocument() expect(screen.queryByTestId('dataset-nav')).not.toBeInTheDocument()
}) })
@ -230,7 +227,7 @@ describe('Header', () => {
mockMedia = 'mobile' mockMedia = 'mobile'
mockIsDatasetOperator = true mockIsDatasetOperator = true
render(<Header />) renderHeader()
expect(screen.queryByTestId('explore-nav')).not.toBeInTheDocument() expect(screen.queryByTestId('explore-nav')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-nav')).not.toBeInTheDocument() expect(screen.queryByTestId('app-nav')).not.toBeInTheDocument()
@ -243,7 +240,7 @@ describe('Header', () => {
mockEnableBilling = true mockEnableBilling = true
mockPlanType = 'sandbox' mockPlanType = 'sandbox'
render(<Header />) renderHeader()
expect(screen.getByTestId('plan-badge')).toBeInTheDocument() expect(screen.getByTestId('plan-badge')).toBeInTheDocument()
expect(screen.queryByTestId('license-nav')).not.toBeInTheDocument() expect(screen.queryByTestId('license-nav')).not.toBeInTheDocument()

View File

@ -1,22 +1,16 @@
import type { LangGeniusVersionResponse } from '@/models/common' import type { LangGeniusVersionResponse } from '@/models/common'
import type { SystemFeatures } from '@/types/feature' import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useGlobalPublicStore } from '@/context/global-public-context'
import AccountAbout from '../index' import AccountAbout from '../index'
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
let mockIsCEEdition = false let mockIsCEEdition = false
vi.mock('@/config', () => ({ vi.mock('@/config', async (importOriginal) => {
get IS_CE_EDITION() { return mockIsCEEdition }, const actual = await importOriginal<typeof import('@/config')>()
})) return {
...actual,
type GlobalPublicStore = { get IS_CE_EDITION() { return mockIsCEEdition },
systemFeatures: SystemFeatures }
setSystemFeatures: (systemFeatures: SystemFeatures) => void })
}
describe('AccountAbout', () => { describe('AccountAbout', () => {
const mockVersionInfo: LangGeniusVersionResponse = { const mockVersionInfo: LangGeniusVersionResponse = {
@ -34,31 +28,23 @@ describe('AccountAbout', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockIsCEEdition = false mockIsCEEdition = false
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { branding: { enabled: false } },
} as unknown as GlobalPublicStore))
}) })
describe('Rendering', () => { describe('Rendering', () => {
it('should render correctly with version information', () => { it('should render correctly with version information', () => {
// Act renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />, {
render(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />) systemFeatures: { branding: { enabled: false } },
})
// Assert
expect(screen.getByText(/^Version/)).toBeInTheDocument() expect(screen.getByText(/^Version/)).toBeInTheDocument()
expect(screen.getAllByText(/0.6.0/).length).toBeGreaterThan(0) expect(screen.getAllByText(/0.6.0/).length).toBeGreaterThan(0)
}) })
it('should render branding logo if enabled', () => { it('should render branding logo if enabled', () => {
// Arrange renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />, {
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { branding: { enabled: true, workspace_logo: 'custom-logo.png' } }, systemFeatures: { branding: { enabled: true, workspace_logo: 'custom-logo.png' } },
} as unknown as GlobalPublicStore)) })
// Act
render(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />)
// Assert
const img = screen.getByAltText('logo') const img = screen.getByAltText('logo')
expect(img).toBeInTheDocument() expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'custom-logo.png') expect(img).toHaveAttribute('src', 'custom-logo.png')
@ -67,21 +53,16 @@ describe('AccountAbout', () => {
describe('Version Logic', () => { describe('Version Logic', () => {
it('should show "Latest Available" when current version equals latest', () => { it('should show "Latest Available" when current version equals latest', () => {
// Act renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />)
render(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />)
// Assert
expect(screen.getByText(/about.latestAvailable/)).toBeInTheDocument() expect(screen.getByText(/about.latestAvailable/)).toBeInTheDocument()
}) })
it('should show "Now Available" when current version is behind', () => { it('should show "Now Available" when current version is behind', () => {
// Arrange
const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' } const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' }
// Act renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={behindVersionInfo} onCancel={mockOnCancel} />)
render(<AccountAbout langGeniusVersionInfo={behindVersionInfo} onCancel={mockOnCancel} />)
// Assert
expect(screen.getByText(/about.nowAvailable/)).toBeInTheDocument() expect(screen.getByText(/about.nowAvailable/)).toBeInTheDocument()
expect(screen.getByText(/about.updateNow/)).toBeInTheDocument() expect(screen.getByText(/about.updateNow/)).toBeInTheDocument()
}) })
@ -89,33 +70,26 @@ describe('AccountAbout', () => {
describe('Community Edition', () => { describe('Community Edition', () => {
it('should render correctly in Community Edition', () => { it('should render correctly in Community Edition', () => {
// Arrange
mockIsCEEdition = true mockIsCEEdition = true
// Act renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />)
render(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />)
// Assert
expect(screen.getByText(/Open Source License/)).toBeInTheDocument() expect(screen.getByText(/Open Source License/)).toBeInTheDocument()
}) })
it('should hide update button in Community Edition when behind version', () => { it('should hide update button in Community Edition when behind version', () => {
// Arrange
mockIsCEEdition = true mockIsCEEdition = true
const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' } const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' }
// Act renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={behindVersionInfo} onCancel={mockOnCancel} />)
render(<AccountAbout langGeniusVersionInfo={behindVersionInfo} onCancel={mockOnCancel} />)
// Assert
expect(screen.queryByText(/about.updateNow/)).not.toBeInTheDocument() expect(screen.queryByText(/about.updateNow/)).not.toBeInTheDocument()
}) })
}) })
describe('User Interactions', () => { describe('User Interactions', () => {
it('should call onCancel when close button is clicked', () => { it('should call onCancel when close button is clicked', () => {
// Act renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />)
render(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />)
// Modal uses Headless UI Dialog which renders into a portal, so we need to use document // Modal uses Headless UI Dialog which renders into a portal, so we need to use document
const closeButton = document.querySelector('div.absolute.cursor-pointer') const closeButton = document.querySelector('div.absolute.cursor-pointer')

View File

@ -2,15 +2,16 @@
import type { LangGeniusVersionResponse } from '@/models/common' import type { LangGeniusVersionResponse } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import DifyLogo from '@/app/components/base/logo/dify-logo' import DifyLogo from '@/app/components/base/logo/dify-logo'
import Modal from '@/app/components/base/modal' import Modal from '@/app/components/base/modal'
import { IS_CE_EDITION } from '@/config' import { IS_CE_EDITION } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import Link from '@/next/link' import Link from '@/next/link'
import { systemFeaturesQueryOptions } from '@/service/system-features'
type IAccountSettingProps = { type IAccountSettingProps = {
langGeniusVersionInfo: LangGeniusVersionResponse langGeniusVersionInfo: LangGeniusVersionResponse
onCancel: () => void onCancel: () => void
@ -22,7 +23,7 @@ export default function AccountAbout({
}: IAccountSettingProps) { }: IAccountSettingProps) {
const { t } = useTranslation() const { t } = useTranslation()
const isLatest = langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version const isLatest = langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
return ( return (
<Modal <Modal

View File

@ -1,17 +1,23 @@
import type { AppContextValue } from '@/context/app-context' import type { AppContextValue } from '@/context/app-context'
import type { ModalContextState } from '@/context/modal-context' import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context' import type { ProviderContextState } from '@/context/provider-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { SystemFeatures } from '@/types/feature'
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 { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useRouter } from '@/next/navigation' import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common' import { useLogout } from '@/service/use-common'
import AppSelector from '../index' import AppSelector from '../index'
type DeepPartial<T> = T extends Array<infer U>
? Array<U>
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T
vi.mock('../../account-setting', () => ({ vi.mock('../../account-setting', () => ({
default: () => <div data-testid="account-setting">AccountSetting</div>, default: () => <div data-testid="account-setting">AccountSetting</div>,
})) }))
@ -37,10 +43,6 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(), useAppContext: vi.fn(),
})) }))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({ vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(), useProviderContext: vi.fn(),
})) }))
@ -79,15 +81,19 @@ const { mockConfig, mockEnv } = vi.hoisted(() => ({
}, },
}, },
})) }))
vi.mock('@/config', () => ({ vi.mock('@/config', async (importOriginal) => {
get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION }, const actual = await importOriginal<typeof import('@/config')>()
get AMPLITUDE_API_KEY() { return mockConfig.AMPLITUDE_API_KEY }, return {
get isAmplitudeEnabled() { return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY }, ...actual,
get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY }, get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
get SUPPORT_EMAIL_ADDRESS() { return mockConfig.SUPPORT_EMAIL_ADDRESS }, get AMPLITUDE_API_KEY() { return mockConfig.AMPLITUDE_API_KEY },
IS_DEV: false, get isAmplitudeEnabled() { return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY },
IS_CE_EDITION: false, get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY },
})) get SUPPORT_EMAIL_ADDRESS() { return mockConfig.SUPPORT_EMAIL_ADDRESS },
IS_DEV: false,
IS_CE_EDITION: false,
}
})
vi.mock('@/env', () => mockEnv) vi.mock('@/env', () => mockEnv)
const baseAppContextValue: AppContextValue = { const baseAppContextValue: AppContextValue = {
@ -136,20 +142,13 @@ describe('AccountDropdown', () => {
const mockLogout = vi.fn() const mockLogout = vi.fn()
const mockSetShowAccountSettingModal = vi.fn() const mockSetShowAccountSettingModal = vi.fn()
const renderWithRouter = (ui: React.ReactElement) => { const renderWithRouter = (
const queryClient = new QueryClient({ ui: React.ReactElement,
defaultOptions: { options: { systemFeatures?: DeepPartial<SystemFeatures> } = {},
queries: { ) => {
retry: false, return renderWithSystemFeatures(ui, {
}, systemFeatures: options.systemFeatures ?? { branding: { enabled: false } },
},
}) })
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
} }
beforeEach(() => { beforeEach(() => {
@ -159,10 +158,6 @@ describe('AccountDropdown', () => {
mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'show' mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'show'
vi.mocked(useAppContext).mockReturnValue(baseAppContextValue) vi.mocked(useAppContext).mockReturnValue(baseAppContextValue)
vi.mocked(useGlobalPublicStore).mockImplementation((selector?: unknown) => {
const fullState = { systemFeatures: { branding: { enabled: false } }, setSystemFeatures: vi.fn() }
return typeof selector === 'function' ? (selector as (state: typeof fullState) => unknown)(fullState) : fullState
})
vi.mocked(useProviderContext).mockReturnValue({ vi.mocked(useProviderContext).mockReturnValue({
isEducationAccount: false, isEducationAccount: false,
plan: { type: Plan.sandbox }, plan: { type: Plan.sandbox },
@ -316,14 +311,10 @@ describe('AccountDropdown', () => {
describe('Branding and Environment', () => { describe('Branding and Environment', () => {
it('should hide sections when branding is enabled', () => { it('should hide sections when branding is enabled', () => {
// Arrange
vi.mocked(useGlobalPublicStore).mockImplementation((selector?: unknown) => {
const fullState = { systemFeatures: { branding: { enabled: true } }, setSystemFeatures: vi.fn() }
return typeof selector === 'function' ? (selector as (state: typeof fullState) => unknown)(fullState) : fullState
})
// Act // Act
renderWithRouter(<AppSelector />) renderWithRouter(<AppSelector />, {
systemFeatures: { branding: { enabled: true } },
})
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByRole('button'))
// Assert // Assert

View File

@ -4,6 +4,7 @@ import type { MouseEventHandler, ReactNode } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar' import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils' import { resetUser } from '@/app/components/base/amplitude/utils'
@ -12,13 +13,13 @@ import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config' import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n' import { useDocLink } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { env } from '@/env' import { env } from '@/env'
import Link from '@/next/link' import Link from '@/next/link'
import { useRouter } from '@/next/navigation' import { useRouter } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useLogout } from '@/service/use-common' import { useLogout } from '@/service/use-common'
import AccountAbout from '../account-about' import AccountAbout from '../account-about'
import GithubStar from '../github-star' import GithubStar from '../github-star'
@ -110,7 +111,7 @@ export default function AppSelector() {
const router = useRouter() const router = useRouter()
const [aboutVisible, setAboutVisible] = useState(false) const [aboutVisible, setAboutVisible] = useState(false)
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const { systemFeatures } = useGlobalPublicStore() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { t } = useTranslation() const { t } = useTranslation()
const docLink = useDocLink() const docLink = useDocLink()

View File

@ -1,8 +1,8 @@
import type { AccountSettingTab } from '../constants' import type { AccountSettingTab } from '../constants'
import type { AppContextValue } from '@/context/app-context' import type { AppContextValue } from '@/context/app-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react' import { useState } from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context' import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@ -47,36 +47,6 @@ vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(), default: vi.fn(),
})) }))
vi.mock('@/context/global-public-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/global-public-context')>()
const systemFeatures = {
...actual.useGlobalPublicStore.getState().systemFeatures,
webapp_auth: {
...actual.useGlobalPublicStore.getState().systemFeatures.webapp_auth,
enabled: true,
},
branding: {
...actual.useGlobalPublicStore.getState().systemFeatures.branding,
enabled: false,
},
enable_marketplace: true,
enable_collaboration_mode: false,
}
return {
...actual,
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
systemFeatures,
}),
useSystemFeaturesQuery: () => ({
data: systemFeatures,
isPending: false,
isLoading: false,
isFetching: false,
}),
}
})
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })), useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })),
useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })), useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })),
@ -176,11 +146,14 @@ describe('AccountSetting', () => {
) )
} }
return render( return renderWithSystemFeatures(<StatefulAccountSetting />, {
<QueryClientProvider client={new QueryClient()}> systemFeatures: {
<StatefulAccountSetting /> webapp_auth: { enabled: true },
</QueryClientProvider>, branding: { enabled: false },
) enable_marketplace: true,
enable_collaboration_mode: false,
},
})
} }
beforeEach(() => { beforeEach(() => {

View File

@ -1,12 +1,11 @@
import type { UseQueryResult } from '@tanstack/react-query' import type { UseQueryResult } from '@tanstack/react-query'
import type { DataSourceAuth } from '../types' import type { DataSourceAuth } from '../types'
import { render, screen } from '@testing-library/react' import { screen } from '@testing-library/react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth' import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useRenderI18nObject } from '@/hooks/use-i18n' import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useGetDataSourceListAuth, useGetDataSourceOAuthUrl } from '@/service/use-datasource' import { useGetDataSourceListAuth, useGetDataSourceOAuthUrl } from '@/service/use-datasource'
import { defaultSystemFeatures } from '@/types/feature'
import { useDataSourceAuthUpdate, useMarketplaceAllPlugins } from '../hooks' import { useDataSourceAuthUpdate, useMarketplaceAllPlugins } from '../hooks'
import DataSourcePage from '../index' import DataSourcePage from '../index'
@ -24,10 +23,6 @@ vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: vi.fn(), useRenderI18nObject: vi.fn(),
})) }))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/service/use-datasource', () => ({ vi.mock('@/service/use-datasource', () => ({
useGetDataSourceListAuth: vi.fn(), useGetDataSourceListAuth: vi.fn(),
useGetDataSourceOAuthUrl: vi.fn(), useGetDataSourceOAuthUrl: vi.fn(),
@ -96,18 +91,14 @@ describe('DataSourcePage Component', () => {
describe('Initial View Rendering', () => { describe('Initial View Rendering', () => {
it('should render an empty view when no data is available and marketplace is disabled', () => { it('should render an empty view when no data is available and marketplace is disabled', () => {
// Arrange // Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: false },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({ vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: undefined, data: undefined,
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act // Act
render(<DataSourcePage />) renderWithSystemFeatures(<DataSourcePage />, {
systemFeatures: { enable_marketplace: false },
})
// Assert // Assert
expect(screen.queryByText('Dify Source')).not.toBeInTheDocument() expect(screen.queryByText('Dify Source')).not.toBeInTheDocument()
@ -118,18 +109,14 @@ describe('DataSourcePage Component', () => {
describe('Data Source List Rendering', () => { describe('Data Source List Rendering', () => {
it('should render Card components for each data source returned from the API', () => { it('should render Card components for each data source returned from the API', () => {
// Arrange // Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: false },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({ vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: mockProviders }, data: { result: mockProviders },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act // Act
render(<DataSourcePage />) renderWithSystemFeatures(<DataSourcePage />, {
systemFeatures: { enable_marketplace: false },
})
// Assert // Assert
expect(screen.getByText('Dify Source')).toBeInTheDocument() expect(screen.getByText('Dify Source')).toBeInTheDocument()
@ -140,18 +127,14 @@ describe('DataSourcePage Component', () => {
describe('Marketplace Integration', () => { describe('Marketplace Integration', () => {
it('should render the InstallFromMarketplace component when enable_marketplace feature is enabled', () => { it('should render the InstallFromMarketplace component when enable_marketplace feature is enabled', () => {
// Arrange // Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: true },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({ vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: mockProviders }, data: { result: mockProviders },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act // Act
render(<DataSourcePage />) renderWithSystemFeatures(<DataSourcePage />, {
systemFeatures: { enable_marketplace: true },
})
// Assert // Assert
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument() expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
@ -160,18 +143,14 @@ describe('DataSourcePage Component', () => {
it('should pass an empty array to InstallFromMarketplace if data result is missing but marketplace is enabled', () => { it('should pass an empty array to InstallFromMarketplace if data result is missing but marketplace is enabled', () => {
// Arrange // Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: true },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({ vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: undefined, data: undefined,
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act // Act
render(<DataSourcePage />) renderWithSystemFeatures(<DataSourcePage />, {
systemFeatures: { enable_marketplace: true },
})
// Assert // Assert
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument() expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
@ -179,38 +158,30 @@ describe('DataSourcePage Component', () => {
it('should handle the case where data exists but result is an empty array', () => { it('should handle the case where data exists but result is an empty array', () => {
// Arrange // Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: true },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({ vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: [] }, data: { result: [] },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act // Act
render(<DataSourcePage />) renderWithSystemFeatures(<DataSourcePage />, {
systemFeatures: { enable_marketplace: true },
})
// Assert // Assert
expect(screen.queryByText('Dify Source')).not.toBeInTheDocument() expect(screen.queryByText('Dify Source')).not.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument() expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
}) })
it('should handle the case where systemFeatures is missing (edge case for coverage)', () => { it('should handle the case where enable_marketplace is false (edge case for coverage)', () => {
// Arrange // Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: {},
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({ vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: [] }, data: { result: [] },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act // Act
render(<DataSourcePage />) renderWithSystemFeatures(<DataSourcePage />, {
systemFeatures: { enable_marketplace: false },
})
// Assert // Assert
expect(screen.queryByText('common.modelProvider.installDataSourceProvider')).not.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.installDataSourceProvider')).not.toBeInTheDocument()

View File

@ -1,11 +1,15 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { memo } from 'react' import { memo } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context' import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useGetDataSourceListAuth } from '@/service/use-datasource' import { useGetDataSourceListAuth } from '@/service/use-datasource'
import Card from './card' import Card from './card'
import InstallFromMarketplace from './install-from-marketplace' import InstallFromMarketplace from './install-from-marketplace'
const DataSourcePage = () => { const DataSourcePage = () => {
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: s => s.enable_marketplace,
})
const { data } = useGetDataSourceListAuth() const { data } = useGetDataSourceListAuth()
return ( return (

View File

@ -1,23 +1,26 @@
import type { AppContextValue } from '@/context/app-context' import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace, Member } from '@/models/common' import type { ICurrentWorkspace, Member } from '@/models/common'
import { render, screen } from '@testing-library/react' import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { vi } from 'vitest' import { vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useMembers } from '@/service/use-common' import { useMembers } from '@/service/use-common'
import MembersPage from '../index' import MembersPage from '../index'
vi.mock('@/context/app-context') vi.mock('@/context/app-context')
vi.mock('@/context/global-public-context')
vi.mock('@/context/provider-context') vi.mock('@/context/provider-context')
vi.mock('@/hooks/use-format-time-from-now') vi.mock('@/hooks/use-format-time-from-now')
vi.mock('@/service/use-common') vi.mock('@/service/use-common')
const renderMembersPage = () => renderWithSystemFeatures(<MembersPage />, {
systemFeatures: { is_email_setup: true },
})
vi.mock('../edit-workspace-modal', () => ({ vi.mock('../edit-workspace-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => ( default: ({ onCancel }: { onCancel: () => void }) => (
<div> <div>
@ -112,10 +115,6 @@ describe('MembersPage', () => {
refetch: mockRefetch, refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>) } as unknown as ReturnType<typeof useMembers>)
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { is_email_setup: true },
} as unknown as Parameters<typeof selector>[0]))
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: false, enableBilling: false,
isAllowTransferWorkspace: true, isAllowTransferWorkspace: true,
@ -127,7 +126,7 @@ describe('MembersPage', () => {
}) })
it('should render workspace and member information', () => { it('should render workspace and member information', () => {
render(<MembersPage />) renderMembersPage()
expect(screen.getByText('Test Workspace'))!.toBeInTheDocument() expect(screen.getByText('Test Workspace'))!.toBeInTheDocument()
expect(screen.getByText('Owner User'))!.toBeInTheDocument() expect(screen.getByText('Owner User'))!.toBeInTheDocument()
@ -137,7 +136,7 @@ describe('MembersPage', () => {
it('should open and close invite modal', async () => { it('should open and close invite modal', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<MembersPage />) renderMembersPage()
await user.click(screen.getByRole('button', { name: /invite/i })) await user.click(screen.getByRole('button', { name: /invite/i }))
expect(screen.getByText('Invite Modal'))!.toBeInTheDocument() expect(screen.getByText('Invite Modal'))!.toBeInTheDocument()
@ -149,7 +148,7 @@ describe('MembersPage', () => {
it('should open invited modal after invite results are sent', async () => { it('should open invited modal after invite results are sent', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<MembersPage />) renderMembersPage()
await user.click(screen.getByRole('button', { name: /invite/i })) await user.click(screen.getByRole('button', { name: /invite/i }))
await user.click(screen.getByRole('button', { name: 'Send Invite Results' })) await user.click(screen.getByRole('button', { name: 'Send Invite Results' }))
@ -164,7 +163,7 @@ describe('MembersPage', () => {
it('should open transfer ownership modal when transfer action is used', async () => { it('should open transfer ownership modal when transfer action is used', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<MembersPage />) renderMembersPage()
await user.click(screen.getByRole('button', { name: /transfer ownership/i })) await user.click(screen.getByRole('button', { name: /transfer ownership/i }))
expect(screen.getByText('Transfer Ownership Modal'))!.toBeInTheDocument() expect(screen.getByText('Transfer Ownership Modal'))!.toBeInTheDocument()
@ -176,7 +175,7 @@ describe('MembersPage', () => {
isAllowTransferWorkspace: false, isAllowTransferWorkspace: false,
})) }))
render(<MembersPage />) renderMembersPage()
expect(screen.getByText('common.members.owner'))!.toBeInTheDocument() expect(screen.getByText('common.members.owner'))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
@ -190,7 +189,7 @@ describe('MembersPage', () => {
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
} as unknown as AppContextValue) } as unknown as AppContextValue)
render(<MembersPage />) renderMembersPage()
expect(screen.queryByRole('button', { name: /invite/i })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /invite/i })).not.toBeInTheDocument()
expect(screen.queryByText('Transfer ownership')).not.toBeInTheDocument() expect(screen.queryByText('Transfer ownership')).not.toBeInTheDocument()
@ -199,7 +198,7 @@ describe('MembersPage', () => {
it('should open and close edit workspace modal', async () => { it('should open and close edit workspace modal', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<MembersPage />) renderMembersPage()
await user.click(screen.getByTestId('edit-workspace-pencil')) await user.click(screen.getByTestId('edit-workspace-pencil'))
expect(screen.getByText('Edit Workspace Modal'))!.toBeInTheDocument() expect(screen.getByText('Edit Workspace Modal'))!.toBeInTheDocument()
@ -211,7 +210,7 @@ describe('MembersPage', () => {
it('should close transfer ownership modal when close is clicked', async () => { it('should close transfer ownership modal when close is clicked', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<MembersPage />) renderMembersPage()
await user.click(screen.getByRole('button', { name: /transfer ownership/i })) await user.click(screen.getByRole('button', { name: /transfer ownership/i }))
expect(screen.getByText('Transfer Ownership Modal'))!.toBeInTheDocument() expect(screen.getByText('Transfer Ownership Modal'))!.toBeInTheDocument()
@ -230,7 +229,7 @@ describe('MembersPage', () => {
refetch: mockRefetch, refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>) } as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />) renderMembersPage()
expect(screen.getByText(/members\.pending/i))!.toBeInTheDocument() expect(screen.getByText(/members\.pending/i))!.toBeInTheDocument()
expect(screen.getByText(/members\.you/i))!.toBeInTheDocument() // Current user is owner@example.com expect(screen.getByText(/members\.you/i))!.toBeInTheDocument() // Current user is owner@example.com
@ -245,7 +244,7 @@ describe('MembersPage', () => {
} as unknown as ReturnType<typeof useProviderContext>['plan'], } as unknown as ReturnType<typeof useProviderContext>['plan'],
})) }))
render(<MembersPage />) renderMembersPage()
expect(screen.getByText(/plansCommon\.member/i))!.toBeInTheDocument() expect(screen.getByText(/plansCommon\.member/i))!.toBeInTheDocument()
expect(screen.getByText('2'))!.toBeInTheDocument() // accounts.length expect(screen.getByText('2'))!.toBeInTheDocument() // accounts.length
@ -262,7 +261,7 @@ describe('MembersPage', () => {
} as unknown as ReturnType<typeof useProviderContext>['plan'], } as unknown as ReturnType<typeof useProviderContext>['plan'],
})) }))
render(<MembersPage />) renderMembersPage()
expect(screen.getByText(/plansCommon\.unlimited/i))!.toBeInTheDocument() expect(screen.getByText(/plansCommon\.unlimited/i))!.toBeInTheDocument()
}) })
@ -276,7 +275,7 @@ describe('MembersPage', () => {
} as unknown as ReturnType<typeof useProviderContext>['plan'], } as unknown as ReturnType<typeof useProviderContext>['plan'],
})) }))
render(<MembersPage />) renderMembersPage()
// Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout // Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
// Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout // Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
@ -291,7 +290,7 @@ describe('MembersPage', () => {
isCurrentWorkspaceManager: true, isCurrentWorkspaceManager: true,
} as unknown as AppContextValue) } as unknown as AppContextValue)
render(<MembersPage />) renderMembersPage()
expect(screen.getByRole('button', { name: /invite/i }))!.toBeInTheDocument() expect(screen.getByRole('button', { name: /invite/i }))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
@ -308,7 +307,7 @@ describe('MembersPage', () => {
refetch: mockRefetch, refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>) } as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />) renderMembersPage()
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1700000000000) expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1700000000000)
}) })
@ -326,7 +325,7 @@ describe('MembersPage', () => {
} as unknown as ReturnType<typeof useProviderContext>['plan'], } as unknown as ReturnType<typeof useProviderContext>['plan'],
})) }))
render(<MembersPage />) renderMembersPage()
expect(screen.getByText(/plansCommon\.member/i))!.toBeInTheDocument() expect(screen.getByText(/plansCommon\.member/i))!.toBeInTheDocument()
expect(screen.getByText('1'))!.toBeInTheDocument() expect(screen.getByText('1'))!.toBeInTheDocument()
@ -338,7 +337,7 @@ describe('MembersPage', () => {
refetch: mockRefetch, refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>) } as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />) renderMembersPage()
expect(screen.getByText(/plansCommon\.memberAfter/i))!.toBeInTheDocument() expect(screen.getByText(/plansCommon\.memberAfter/i))!.toBeInTheDocument()
expect(screen.getByText('1'))!.toBeInTheDocument() expect(screen.getByText('1'))!.toBeInTheDocument()
@ -356,7 +355,7 @@ describe('MembersPage', () => {
refetch: mockRefetch, refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>) } as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />) renderMembersPage()
expect(screen.getByText('common.members.normal'))!.toBeInTheDocument() expect(screen.getByText('common.members.normal'))!.toBeInTheDocument()
}) })
@ -370,7 +369,7 @@ describe('MembersPage', () => {
} as unknown as ReturnType<typeof useProviderContext>['plan'], } as unknown as ReturnType<typeof useProviderContext>['plan'],
})) }))
render(<MembersPage />) renderMembersPage()
expect(screen.getByText('Upgrade Button'))!.toBeInTheDocument() expect(screen.getByText('Upgrade Button'))!.toBeInTheDocument()
}) })

View File

@ -1,35 +1,34 @@
import type { AppContextValue } from '@/context/app-context' import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common' import type { ICurrentWorkspace } from '@/models/common'
import { render, screen } from '@testing-library/react' import { screen } from '@testing-library/react'
import { vi } from 'vitest' import { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWorkspacePermissions } from '@/service/use-workspace' import { useWorkspacePermissions } from '@/service/use-workspace'
import InviteButton from '../invite-button' import InviteButton from '../invite-button'
vi.mock('@/context/app-context') vi.mock('@/context/app-context')
vi.mock('@/context/global-public-context')
vi.mock('@/service/use-workspace') vi.mock('@/service/use-workspace')
describe('InviteButton', () => { describe('InviteButton', () => {
const setupMocks = ({ const setupPermissions = ({
brandingEnabled,
isFetching, isFetching,
allowInvite, allowInvite,
}: { }: {
brandingEnabled: boolean
isFetching: boolean isFetching: boolean
allowInvite?: boolean allowInvite?: boolean
}) => { }) => {
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { branding: { enabled: brandingEnabled } },
} as unknown as Parameters<typeof selector>[0]))
vi.mocked(useWorkspacePermissions).mockReturnValue({ vi.mocked(useWorkspacePermissions).mockReturnValue({
data: allowInvite === undefined ? null : { allow_member_invite: allowInvite }, data: allowInvite === undefined ? null : { allow_member_invite: allowInvite },
isFetching, isFetching,
} as unknown as ReturnType<typeof useWorkspacePermissions>) } as unknown as ReturnType<typeof useWorkspacePermissions>)
} }
const renderInviteButton = (brandingEnabled: boolean) =>
renderWithSystemFeatures(<InviteButton />, {
systemFeatures: { branding: { enabled: brandingEnabled } },
})
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({ vi.mocked(useAppContext).mockReturnValue({
@ -38,33 +37,33 @@ describe('InviteButton', () => {
}) })
it('should show invite button when branding is disabled', () => { it('should show invite button when branding is disabled', () => {
setupMocks({ brandingEnabled: false, isFetching: false }) setupPermissions({ isFetching: false })
render(<InviteButton />) renderInviteButton(false)
expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument()
}) })
it('should show loading status while permissions are loading', () => { it('should show loading status while permissions are loading', () => {
setupMocks({ brandingEnabled: true, isFetching: true }) setupPermissions({ isFetching: true })
render(<InviteButton />) renderInviteButton(true)
expect(screen.getByRole('status')).toBeInTheDocument() expect(screen.getByRole('status')).toBeInTheDocument()
}) })
it('should hide invite button when permission is denied', () => { it('should hide invite button when permission is denied', () => {
setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: false }) setupPermissions({ isFetching: false, allowInvite: false })
render(<InviteButton />) renderInviteButton(true)
expect(screen.queryByRole('button', { name: /members\.invite/i })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /members\.invite/i })).not.toBeInTheDocument()
}) })
it('should show invite button when permission is granted', () => { it('should show invite button when permission is granted', () => {
setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: true }) setupPermissions({ isFetching: false, allowInvite: true })
render(<InviteButton />) renderInviteButton(true)
expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument()
}) })

View File

@ -2,17 +2,18 @@
import type { InvitationResult } from '@/models/common' import type { InvitationResult } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar' import { Avatar } from '@langgenius/dify-ui/avatar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { NUM_INFINITE } from '@/app/components/billing/config' import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn' import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useLocale } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useMembers } from '@/service/use-common' import { useMembers } from '@/service/use-common'
import EditWorkspaceModal from './edit-workspace-modal' import EditWorkspaceModal from './edit-workspace-modal'
import InviteButton from './invite-button' import InviteButton from './invite-button'
@ -35,7 +36,7 @@ const MembersPage = () => {
const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext() const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext()
const { data, refetch } = useMembers() const { data, refetch } = useMembers()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { formatTimeFromNow } = useFormatTimeFromNow() const { formatTimeFromNow } = useFormatTimeFromNow()
const [inviteModalVisible, setInviteModalVisible] = useState(false) const [inviteModalVisible, setInviteModalVisible] = useState(false)
const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([]) const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([])

View File

@ -1,9 +1,10 @@
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { RiUserAddLine } from '@remixicon/react' import { RiUserAddLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context' import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useWorkspacePermissions } from '@/service/use-workspace' import { useWorkspacePermissions } from '@/service/use-workspace'
type InviteButtonProps = { type InviteButtonProps = {
@ -14,7 +15,7 @@ type InviteButtonProps = {
const InviteButton = (props: InviteButtonProps) => { const InviteButton = (props: InviteButtonProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { currentWorkspace } = useAppContext() const { currentWorkspace } = useAppContext()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspace!.id, systemFeatures.branding.enabled) const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspace!.id, systemFeatures.branding.enabled)
if (systemFeatures.branding.enabled) { if (systemFeatures.branding.enabled) {
if (isFetchingWorkspacePermissions) { if (isFetchingWorkspacePermissions) {

View File

@ -1,36 +1,38 @@
import type { AppContextValue } from '@/context/app-context' import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common' import type { ICurrentWorkspace } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { vi } from 'vitest' import { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWorkspacePermissions } from '@/service/use-workspace' import { useWorkspacePermissions } from '@/service/use-workspace'
import TransferOwnership from '../transfer-ownership' import TransferOwnership from '../transfer-ownership'
vi.mock('@/context/app-context') vi.mock('@/context/app-context')
vi.mock('@/context/global-public-context')
vi.mock('@/service/use-workspace') vi.mock('@/service/use-workspace')
describe('TransferOwnership', () => { describe('TransferOwnership', () => {
const setupMocks = ({ const setupPermissions = ({
brandingEnabled,
isFetching, isFetching,
allowOwnerTransfer, allowOwnerTransfer,
}: { }: {
brandingEnabled: boolean
isFetching: boolean isFetching: boolean
allowOwnerTransfer?: boolean allowOwnerTransfer?: boolean
}) => { }) => {
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { branding: { enabled: brandingEnabled } },
} as unknown as Parameters<typeof selector>[0]))
vi.mocked(useWorkspacePermissions).mockReturnValue({ vi.mocked(useWorkspacePermissions).mockReturnValue({
data: allowOwnerTransfer === undefined ? null : { allow_owner_transfer: allowOwnerTransfer }, data: allowOwnerTransfer === undefined ? null : { allow_owner_transfer: allowOwnerTransfer },
isFetching, isFetching,
} as unknown as ReturnType<typeof useWorkspacePermissions>) } as unknown as ReturnType<typeof useWorkspacePermissions>)
} }
const renderTransferOwnership = (
brandingEnabled: boolean,
onOperate: () => void = vi.fn(),
) =>
renderWithSystemFeatures(<TransferOwnership onOperate={onOperate} />, {
systemFeatures: { branding: { enabled: brandingEnabled } },
})
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({ vi.mocked(useAppContext).mockReturnValue({
@ -39,17 +41,17 @@ describe('TransferOwnership', () => {
}) })
it('should show loading status while permissions are loading', () => { it('should show loading status while permissions are loading', () => {
setupMocks({ brandingEnabled: true, isFetching: true }) setupPermissions({ isFetching: true })
render(<TransferOwnership onOperate={vi.fn()} />) renderTransferOwnership(true)
expect(screen.getByRole('status')).toBeInTheDocument() expect(screen.getByRole('status')).toBeInTheDocument()
}) })
it('should show owner text without transfer menu when transfer is forbidden', () => { it('should show owner text without transfer menu when transfer is forbidden', () => {
setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: false }) setupPermissions({ isFetching: false, allowOwnerTransfer: false })
render(<TransferOwnership onOperate={vi.fn()} />) renderTransferOwnership(true)
expect(screen.getByText(/members\.owner/i)).toBeInTheDocument() expect(screen.getByText(/members\.owner/i)).toBeInTheDocument()
expect(screen.queryByText(/members\.transferOwnership/i)).toBeNull() expect(screen.queryByText(/members\.transferOwnership/i)).toBeNull()
@ -59,9 +61,9 @@ describe('TransferOwnership', () => {
const user = userEvent.setup() const user = userEvent.setup()
const onOperate = vi.fn() const onOperate = vi.fn()
setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: true }) setupPermissions({ isFetching: false, allowOwnerTransfer: true })
render(<TransferOwnership onOperate={onOperate} />) renderTransferOwnership(true, onOperate)
await user.click(screen.getByRole('button', { name: /members\.owner/i })) await user.click(screen.getByRole('button', { name: /members\.owner/i }))
const transferOptionText = await screen.findByText(/members\.transferOwnership/i) const transferOptionText = await screen.findByText(/members\.transferOwnership/i)
@ -78,9 +80,9 @@ describe('TransferOwnership', () => {
it('should allow transfer menu when branding is disabled', async () => { it('should allow transfer menu when branding is disabled', async () => {
const user = userEvent.setup() const user = userEvent.setup()
setupMocks({ brandingEnabled: false, isFetching: false }) setupPermissions({ isFetching: false })
render(<TransferOwnership onOperate={vi.fn()} />) renderTransferOwnership(false)
await user.click(screen.getByRole('button', { name: /members\.owner/i })) await user.click(screen.getByRole('button', { name: /members\.owner/i }))

View File

@ -4,11 +4,12 @@ import { cn } from '@langgenius/dify-ui/cn'
import { import {
RiArrowDownSLine, RiArrowDownSLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { Fragment } from 'react' import { Fragment } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context' import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useWorkspacePermissions } from '@/service/use-workspace' import { useWorkspacePermissions } from '@/service/use-workspace'
type Props = { type Props = {
@ -18,7 +19,7 @@ type Props = {
const TransferOwnership = ({ onOperate }: Props) => { const TransferOwnership = ({ onOperate }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { currentWorkspace } = useAppContext() const { currentWorkspace } = useAppContext()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspace!.id, systemFeatures.branding.enabled) const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspace!.id, systemFeatures.branding.enabled)
if (systemFeatures.branding.enabled) { if (systemFeatures.branding.enabled) {
if (isFetchingWorkspacePermissions) { if (isFetchingWorkspacePermissions) {

View File

@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react' import { screen } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { import {
CurrentSystemQuotaTypeEnum, CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum, CustomConfigurationStatusEnum,
@ -15,17 +16,13 @@ const mockQuotaConfig = {
is_valid: true, is_valid: true,
} }
vi.mock('@/config', () => ({ vi.mock('@/config', async (importOriginal) => {
IS_CLOUD_EDITION: false, const actual = await importOriginal<typeof import('@/config')>()
})) return {
...actual,
vi.mock('@/context/global-public-context', () => ({ IS_CLOUD_EDITION: false,
useSystemFeaturesQuery: () => ({ }
data: { })
enable_marketplace: false,
},
}),
}))
vi.mock('@/context/provider-context', () => ({ vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ useProviderContext: () => ({
@ -62,26 +59,41 @@ vi.mock('../install-from-marketplace', () => ({
default: () => <div data-testid="install-from-marketplace" />, default: () => <div data-testid="install-from-marketplace" />,
})) }))
vi.mock('@tanstack/react-query', async (importOriginal) => { vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>() const actual = await importOriginal<typeof import('@/service/client')>()
const originalPlugins = actual.consoleQuery.plugins as unknown as Record<string, unknown>
return { return {
...actual, ...actual,
useQuery: () => ({ data: undefined }), consoleQuery: new Proxy(actual.consoleQuery, {
get(target, prop) {
if (prop === 'plugins') {
return {
...originalPlugins,
checkInstalled: {
queryOptions: () => ({
queryKey: ['plugins', 'checkInstalled'],
queryFn: () => new Promise(() => {}),
}),
},
latestVersions: {
queryOptions: () => ({
queryKey: ['plugins', 'latestVersions'],
queryFn: () => new Promise(() => {}),
}),
},
}
}
return Reflect.get(target, prop)
},
}),
} }
}) })
vi.mock('@/service/client', () => ({
consoleQuery: {
plugins: {
checkInstalled: { queryOptions: () => ({}) },
latestVersions: { queryOptions: () => ({}) },
},
},
}))
describe('ModelProviderPage non-cloud branch', () => { describe('ModelProviderPage non-cloud branch', () => {
it('should skip the quota panel when cloud edition is disabled', () => { it('should skip the quota panel when cloud edition is disabled', () => {
render(<ModelProviderPage searchText="" />) renderWithSystemFeatures(<ModelProviderPage searchText="" />, {
systemFeatures: { enable_marketplace: false },
})
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument() expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
expect(screen.queryByTestId('quota-panel')).not.toBeInTheDocument() expect(screen.queryByTestId('quota-panel')).not.toBeInTheDocument()

View File

@ -1,5 +1,6 @@
import { act, render, screen } from '@testing-library/react' import { act, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { import {
CurrentSystemQuotaTypeEnum, CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum, CustomConfigurationStatusEnum,
@ -7,8 +8,6 @@ import {
} from '../declarations' } from '../declarations'
import ModelProviderPage from '../index' import ModelProviderPage from '../index'
let mockEnableMarketplace = true
const mockQuotaConfig = { const mockQuotaConfig = {
quota_type: CurrentSystemQuotaTypeEnum.free, quota_type: CurrentSystemQuotaTypeEnum.free,
quota_unit: QuotaUnitEnum.times, quota_unit: QuotaUnitEnum.times,
@ -18,13 +17,14 @@ const mockQuotaConfig = {
is_valid: true, is_valid: true,
} }
vi.mock('@/context/global-public-context', () => ({ const renderModelProviderPage = (
useSystemFeaturesQuery: () => ({ props: { searchText?: string, enableMarketplace?: boolean } = {},
data: { ) => {
enable_marketplace: mockEnableMarketplace, const { searchText = '', enableMarketplace = true } = props
}, return renderWithSystemFeatures(<ModelProviderPage searchText={searchText} />, {
}), systemFeatures: { enable_marketplace: enableMarketplace },
})) })
}
const mockProviders = [ const mockProviders = [
{ {
@ -83,28 +83,40 @@ vi.mock('../system-model-selector', () => ({
default: () => <div data-testid="system-model-selector" />, default: () => <div data-testid="system-model-selector" />,
})) }))
vi.mock('@tanstack/react-query', async (importOriginal) => { vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>() const actual = await importOriginal<typeof import('@/service/client')>()
const originalPlugins = actual.consoleQuery.plugins as unknown as Record<string, unknown>
return { return {
...actual, ...actual,
useQuery: () => ({ data: undefined }), consoleQuery: new Proxy(actual.consoleQuery, {
get(target, prop) {
if (prop === 'plugins') {
return {
...originalPlugins,
checkInstalled: {
queryOptions: () => ({
queryKey: ['plugins', 'checkInstalled'],
queryFn: () => new Promise(() => {}),
}),
},
latestVersions: {
queryOptions: () => ({
queryKey: ['plugins', 'latestVersions'],
queryFn: () => new Promise(() => {}),
}),
},
}
}
return Reflect.get(target, prop)
},
}),
} }
}) })
vi.mock('@/service/client', () => ({
consoleQuery: {
plugins: {
checkInstalled: { queryOptions: () => ({}) },
latestVersions: { queryOptions: () => ({}) },
},
},
}))
describe('ModelProviderPage', () => { describe('ModelProviderPage', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers() vi.useFakeTimers()
vi.clearAllMocks() vi.clearAllMocks()
mockEnableMarketplace = true
Object.keys(mockDefaultModels).forEach((key) => { Object.keys(mockDefaultModels).forEach((key) => {
mockDefaultModels[key] = { data: null, isLoading: false } mockDefaultModels[key] = { data: null, isLoading: false }
}) })
@ -134,21 +146,21 @@ describe('ModelProviderPage', () => {
}) })
it('should render main elements', () => { it('should render main elements', () => {
render(<ModelProviderPage searchText="" />) renderModelProviderPage()
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument() expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument() expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument() expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument()
}) })
it('should render configured and not configured providers sections', () => { it('should render configured and not configured providers sections', () => {
render(<ModelProviderPage searchText="" />) renderModelProviderPage()
expect(screen.getByText('openai')).toBeInTheDocument() expect(screen.getByText('openai')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument() expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
expect(screen.getByText('anthropic')).toBeInTheDocument() expect(screen.getByText('anthropic')).toBeInTheDocument()
}) })
it('should filter providers based on search text', () => { it('should filter providers based on search text', () => {
render(<ModelProviderPage searchText="open" />) renderModelProviderPage({ searchText: 'open' })
act(() => { act(() => {
vi.advanceTimersByTime(600) vi.advanceTimersByTime(600)
}) })
@ -157,7 +169,7 @@ describe('ModelProviderPage', () => {
}) })
it('should show empty state if no configured providers match', () => { it('should show empty state if no configured providers match', () => {
render(<ModelProviderPage searchText="non-existent" />) renderModelProviderPage({ searchText: 'non-existent' })
act(() => { act(() => {
vi.advanceTimersByTime(600) vi.advanceTimersByTime(600)
}) })
@ -165,9 +177,7 @@ describe('ModelProviderPage', () => {
}) })
it('should hide marketplace section when marketplace feature is disabled', () => { it('should hide marketplace section when marketplace feature is disabled', () => {
mockEnableMarketplace = false renderModelProviderPage({ enableMarketplace: false })
render(<ModelProviderPage searchText="" />)
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument() expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
}) })
@ -185,14 +195,14 @@ describe('ModelProviderPage', () => {
}, },
}) })
render(<ModelProviderPage searchText="" />) renderModelProviderPage()
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument() expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument()
}) })
it('should show none-configured warning when providers exist but no default models set', () => { it('should show none-configured warning when providers exist but no default models set', () => {
render(<ModelProviderPage searchText="" />) renderModelProviderPage()
expect(screen.getByText('common.modelProvider.noneConfigured')).toBeInTheDocument() expect(screen.getByText('common.modelProvider.noneConfigured')).toBeInTheDocument()
}) })
@ -202,7 +212,7 @@ describe('ModelProviderPage', () => {
isLoading: false, isLoading: false,
} }
render(<ModelProviderPage searchText="" />) renderModelProviderPage()
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument() expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
}) })
@ -217,7 +227,7 @@ describe('ModelProviderPage', () => {
mockDefaultModels.speech2text = makeModel('whisper-1', 'speech2text') mockDefaultModels.speech2text = makeModel('whisper-1', 'speech2text')
mockDefaultModels.tts = makeModel('tts-1', 'tts') mockDefaultModels.tts = makeModel('tts-1', 'tts')
render(<ModelProviderPage searchText="" />) renderModelProviderPage()
expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
@ -228,7 +238,7 @@ describe('ModelProviderPage', () => {
mockDefaultModels[key] = { data: null, isLoading: true } mockDefaultModels[key] = { data: null, isLoading: true }
}) })
render(<ModelProviderPage searchText="" />) renderModelProviderPage()
expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
@ -265,7 +275,7 @@ describe('ModelProviderPage', () => {
}, },
}) })
render(<ModelProviderPage searchText="" />) renderModelProviderPage()
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent) const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
expect(renderedProviders).toEqual([ expect(renderedProviders).toEqual([

Some files were not shown because too many files have changed in this diff Show More