Merge branch 'main' into jzh

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

View File

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

View File

@@ -50,6 +50,7 @@ from fields.dataset_fields import (
from fields.document_fields import document_status_fields
from graphon.model_runtime.entities.model_entities import ModelType
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.dataset import DatasetPermission, DatasetPermissionEnum
from models.enums import ApiTokenType, SegmentStatus
@@ -889,7 +890,8 @@ class DatasetApiBaseUrlApi(Resource):
@login_required
@account_initialization_required
def get(self):
return {"api_base_url": (dify_config.SERVICE_API_URL or request.host_url.rstrip("/")) + "/v1"}
base = dify_config.SERVICE_API_URL or request.host_url.rstrip("/")
return {"api_base_url": normalize_api_base_url(base)}
@console_ns.route("/datasets/retrieval-setting")

View File

@@ -1131,6 +1131,14 @@ class ToolMCPAuthApi(Resource):
with sessionmaker(db.engine).begin() as session:
service = MCPToolManageService(session=session)
service.clear_provider_credentials(provider_id=provider_id, tenant_id=tenant_id)
parsed = urlparse(server_url)
sanitized_url = f"{parsed.scheme}://{parsed.hostname}{parsed.path}"
logger.warning(
"MCP authorization failed for provider %s (url=%s)",
provider_id,
sanitized_url,
exc_info=True,
)
raise ValueError(f"Failed to connect to MCP server: {e}") from e

View File

@@ -303,9 +303,16 @@ class StreamableHTTPTransport:
if response.status_code == 404:
if isinstance(message.root, JSONRPCRequest):
error_msg = (
f"MCP server URL returned 404 Not Found: {self.url} "
"— verify the server URL is correct and the server is running"
if is_initialization
else "Session terminated by server"
)
self._send_session_terminated_error(
ctx.server_to_client_queue,
message.root.id,
message=error_msg,
)
return
@@ -381,12 +388,13 @@ class StreamableHTTPTransport:
self,
server_to_client_queue: ServerToClientQueue,
request_id: RequestId,
message: str = "Session terminated by server",
):
"""Send a session terminated error response."""
jsonrpc_error = JSONRPCError(
jsonrpc="2.0",
id=request_id,
error=ErrorData(code=32600, message="Session terminated by server"),
error=ErrorData(code=32600, message=message),
)
session_message = SessionMessage(JSONRPCMessage(jsonrpc_error))
server_to_client_queue.put(session_message)

View File

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

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

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

View File

@@ -25,6 +25,7 @@ from graphon.enums import WorkflowExecutionStatus
from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType
from graphon.file import helpers as file_helpers
from libs.helper import generate_string # type: ignore[import-not-found]
from libs.url_utils import normalize_api_base_url
from libs.uuid_utils import uuidv7
from models.utils.file_input_compat import build_file_from_input_mapping
@@ -446,7 +447,8 @@ class App(Base):
@property
def api_base_url(self) -> str:
return (dify_config.SERVICE_API_URL or request.host_url.rstrip("/")) + "/v1"
base = dify_config.SERVICE_API_URL or request.host_url.rstrip("/")
return normalize_api_base_url(base)
@property
def tenant(self) -> Tenant | None:

View File

@@ -303,17 +303,22 @@ class AppService:
return app
def update_app_icon(self, app: App, icon: str, icon_background: str) -> App:
def update_app_icon(
self, app: App, icon: str, icon_background: str, icon_type: IconType | str | None = None
) -> App:
"""
Update app icon
:param app: App instance
:param icon: new icon
:param icon_background: new icon_background
:param icon_type: new icon type
:return: App instance
"""
assert current_user is not None
app.icon = icon
app.icon_background = icon_background
if icon_type is not None:
app.icon_type = icon_type if isinstance(icon_type, IconType) else IconType(icon_type)
app.updated_by = current_user.id
app.updated_at = naive_utc_now()
db.session.commit()

View File

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

View File

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

View File

@@ -1772,6 +1772,21 @@ class TestDatasetApiBaseUrlApi:
assert response["api_base_url"] == "http://localhost:5000/v1"
def test_get_api_base_url_no_double_v1(self, app):
api = DatasetApiBaseUrlApi()
method = unwrap(api.get)
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.datasets.dify_config.SERVICE_API_URL",
"https://example.com/v1",
),
):
response = method(api)
assert response["api_base_url"] == "https://example.com/v1"
class TestDatasetRetrievalSettingApi:
def test_get_success(self, app):

View File

@@ -971,6 +971,23 @@ class TestHandlePostRequestNew:
assert isinstance(item, SessionMessage)
assert isinstance(item.message.root, JSONRPCError)
assert item.message.root.id == 77
assert item.message.root.error.message == "Session terminated by server"
def test_404_on_initialization_includes_url_in_error(self):
t = _new_transport(url="http://example.com/mcp/server/abc123/mcp")
q: queue.Queue = queue.Queue()
msg = _make_request_msg("initialize", 1)
ctx = self._make_ctx(t, q, message=msg)
mock_resp = MagicMock()
mock_resp.status_code = 404
ctx.client.stream = self._stream_ctx(mock_resp)
t._handle_post_request(ctx)
item = q.get_nowait()
assert isinstance(item, SessionMessage)
assert isinstance(item.message.root, JSONRPCError)
assert item.message.root.error.code == 32600
assert "404 Not Found" in item.message.root.error.message
assert "http://example.com/mcp/server/abc123/mcp" in item.message.root.error.message
def test_404_for_notification_no_error_sent(self):
t = _new_transport()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import type { RefObject } from 'react'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import { fireEvent, renderHook, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks'
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'

View File

@@ -1,5 +1,6 @@
import { render, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
@@ -19,44 +20,12 @@ vi.mock('@/service/use-share', () => ({
})),
}))
// Store the mock implementation in a way that survives hoisting
const mockGetProcessedSystemVariablesFromUrlParams = vi.fn()
vi.mock('@/app/components/base/chat/utils', () => ({
getProcessedSystemVariablesFromUrlParams: (...args: any[]) => mockGetProcessedSystemVariablesFromUrlParams(...args),
}))
// Use vi.hoisted to define mock state before vi.mock hoisting
const { mockGlobalStoreState } = vi.hoisted(() => ({
mockGlobalStoreState: {
isGlobalPending: false,
setIsGlobalPending: vi.fn(),
systemFeatures: {},
setSystemFeatures: vi.fn(),
},
}))
vi.mock('@/context/global-public-context', () => {
const useGlobalPublicStore = Object.assign(
(selector?: (state: typeof mockGlobalStoreState) => any) =>
selector ? selector(mockGlobalStoreState) : mockGlobalStoreState,
{
setState: (updater: any) => {
if (typeof updater === 'function')
Object.assign(mockGlobalStoreState, updater(mockGlobalStoreState) ?? {})
else
Object.assign(mockGlobalStoreState, updater)
},
__mockState: mockGlobalStoreState,
},
)
return {
useGlobalPublicStore,
useIsSystemFeaturesPending: () => false,
}
})
const TestConsumer = () => {
const embeddedUserId = useWebAppStore(state => state.embeddedUserId)
const embeddedConversationId = useWebAppStore(state => state.embeddedConversationId)
@@ -91,7 +60,6 @@ const initialWebAppStore = (() => {
})()
beforeEach(() => {
mockGlobalStoreState.isGlobalPending = false
mockGetProcessedSystemVariablesFromUrlParams.mockReset()
useWebAppStore.setState(initialWebAppStore, true)
})
@@ -103,7 +71,7 @@ describe('WebAppStoreProvider embedded user id handling', () => {
conversation_id: 'conversation-456',
})
render(
renderWithSystemFeatures(
<WebAppStoreProvider>
<TestConsumer />
</WebAppStoreProvider>,
@@ -125,7 +93,7 @@ describe('WebAppStoreProvider embedded user id handling', () => {
}))
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({})
render(
renderWithSystemFeatures(
<WebAppStoreProvider>
<TestConsumer />
</WebAppStoreProvider>,

View File

@@ -7,7 +7,8 @@
import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import AppList from '@/app/components/explore/app-list'
import { useAppContext } from '@/context/app-context'
import { fetchAppDetail } from '@/service/explore'

View File

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

View File

@@ -1,16 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
import { InstallationScope } from '@/types/feature'
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
plugin_installation_permission: {
restrict_to_marketplace_only: false,
plugin_installation_scope: InstallationScope.ALL,
},
}),
}))
describe('Plugin Marketplace to Install Flow', () => {
describe('install permission validation pipeline', () => {
const systemFeaturesAll = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,27 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import Loading from '@/app/components/base/loading'
import Header from '@/app/signin/_header'
import { AppContextProvider } from '@/context/app-context-provider'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { useIsLogin } from '@/service/use-common'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
export default function SignInLayout({ children }: any) {
const { systemFeatures } = useGlobalPublicStore()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
useDocumentTitle('')
const { isLoading, data: loginData } = useIsLogin()
const isLoggedIn = loginData?.logged_in
// Probe login state. 401 stays as `error` (not thrown) so this layout can render
// the signin/oauth UI for unauthenticated users; other errors bubble to error.tsx.
// (When unauthenticated, service/base.ts's auto-redirect to /signin still fires.)
const { isPending, data: userResp, error } = useQuery({
...userProfileQueryOptions(),
throwOnError: err => !isLegacyBase401(err),
})
const isLoggedIn = !!userResp && !error
if (isLoading) {
if (isPending) {
return (
<div className="flex min-h-screen w-full justify-center bg-background-default-burn">
<Loading />

View File

@@ -10,6 +10,7 @@ import {
RiMailLine,
RiTranslate2,
} from '@remixicon/react'
import { useQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
@@ -17,7 +18,7 @@ import Loading from '@/app/components/base/loading'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
import { useRouter, useSearchParams } from '@/next/navigation'
import { useIsLogin, useUserProfile } from '@/service/use-common'
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
function buildReturnUrl(pathname: string, search: string) {
@@ -61,15 +62,20 @@ export default function OAuthAuthorize() {
const searchParams = useSearchParams()
const client_id = decodeURIComponent(searchParams.get('client_id') || '')
const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '')
const { data: userProfileResp } = useUserProfile()
// Probe user profile. 401 stays as `error` (legitimate "not logged in" state),
// other errors throw to the nearest error.tsx; jumpTo same-pathname guard in
// service/base.ts prevents a redirect loop here.
const { data: userProfileResp, isPending: isProfileLoading, error: profileError } = useQuery({
...userProfileQueryOptions(),
throwOnError: err => !isLegacyBase401(err),
})
const isLoggedIn = !!userProfileResp && !profileError
const userProfile = userProfileResp?.profile
const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri)
const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp()
const hasNotifiedRef = useRef(false)
const { isLoading: isIsLoginLoading, data: loginData } = useIsLogin()
const isLoggedIn = loginData?.logged_in
const isLoading = isOAuthLoading || isIsLoginLoading
const isLoading = isOAuthLoading || isProfileLoading
const onLoginSwitchClick = () => {
try {
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`)

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import RootLoading from '@/app/loading'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
@@ -98,5 +99,5 @@ export const AppInitializer = ({
})()
}, [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 { App } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode, SubjectType } from '@/models/access-control'
import AccessControlDialog from '../access-control-dialog'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,9 @@ import type { i18n } from 'i18next'
import type { ChatConfig } from '../../types'
import type { ChatWithHistoryContextValue } from '../context'
import type { AppData, AppMeta } from '@/models/share'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import * as ReactI18next from 'react-i18next'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useChatWithHistoryContext } from '../context'
import HeaderInMobile from '../header-in-mobile'

View File

@@ -2,7 +2,8 @@ import type { RefObject } from 'react'
import type { ChatConfig } from '../../types'
import type { InstalledApp } from '@/models/explore'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useChatWithHistory } from '../hooks'

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,25 @@
import type { ReactElement } from 'react'
import type { EmbeddedChatbotContextValue } from '../../context'
import type { AppData } from '@/models/share'
import type { SystemFeatures } from '@/types/feature'
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { InstallationScope, LicenseStatus } from '@/types/feature'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useEmbeddedChatbotContext } from '../../context'
import Header from '../index'
let mockBranding = { enabled: true, workspace_logo: '' }
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: { branding: { ...mockBranding } },
})
vi.mock('../../context', () => ({
useEmbeddedChatbotContext: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown', () => ({
default: () => <div data-testid="view-form-dropdown" />,
}))
type GlobalPublicStoreMock = {
systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void
}
describe('EmbeddedChatbot Header', () => {
const defaultAppData: AppData = {
app_id: 'test-app-id',
@@ -47,48 +42,6 @@ describe('EmbeddedChatbot Header', () => {
allInputsHidden: false,
}
const defaultSystemFeatures: SystemFeatures = {
app_dsl_version: '',
trial_models: [],
plugin_installation_permission: {
plugin_installation_scope: InstallationScope.ALL,
restrict_to_marketplace_only: false,
},
sso_enforced_for_signin: false,
sso_enforced_for_signin_protocol: '',
sso_enforced_for_web: false,
sso_enforced_for_web_protocol: '',
enable_marketplace: false,
enable_change_email: false,
enable_email_code_login: false,
enable_email_password_login: false,
enable_social_oauth_login: false,
is_allow_create_workspace: false,
is_allow_register: false,
is_email_setup: false,
license: {
status: LicenseStatus.NONE,
expired_at: '',
},
branding: {
enabled: true,
workspace_logo: '',
login_page_logo: '',
favicon: '',
application_title: '',
},
webapp_auth: {
enabled: false,
allow_sso: false,
sso_config: { protocol: '' },
allow_email_code_login: false,
allow_email_password_login: false,
},
enable_collaboration_mode: false,
enable_trial_app: false,
enable_explore_banner: false,
}
const setupIframe = () => {
const mockPostMessage = vi.fn()
const mockTop = { postMessage: mockPostMessage }
@@ -100,11 +53,8 @@ describe('EmbeddedChatbot Header', () => {
beforeEach(() => {
vi.clearAllMocks()
mockBranding = { enabled: true, workspace_logo: '' }
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(defaultContext as EmbeddedChatbotContextValue)
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: defaultSystemFeatures,
setSystemFeatures: vi.fn(),
}))
Object.defineProperty(window, 'self', { value: window, configurable: true })
Object.defineProperty(window, 'top', { value: window, configurable: true })
@@ -149,16 +99,7 @@ describe('EmbeddedChatbot Header', () => {
})
it('should render workspace logo when branding is enabled and logo exists', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
workspace_logo: 'https://example.com/workspace.png',
},
},
setSystemFeatures: vi.fn(),
}))
mockBranding = { enabled: true, workspace_logo: 'https://example.com/workspace.png' }
render(<Header title="Test Chatbot" />)
@@ -167,32 +108,13 @@ describe('EmbeddedChatbot Header', () => {
})
it('should render Dify logo by default when branding enabled is true but no logo provided', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: '',
},
},
setSystemFeatures: vi.fn(),
}))
mockBranding = { enabled: true, workspace_logo: '' }
render(<Header title="Test Chatbot" />)
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
})
it('should render Dify logo when branding is disabled', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: false,
},
},
setSystemFeatures: vi.fn(),
}))
mockBranding = { enabled: false, workspace_logo: '' }
render(<Header title="Test Chatbot" />)
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
})

View File

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

View File

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

View File

@@ -157,18 +157,6 @@ describe('NewFeaturePanel', () => {
expect(screen.queryByText(/feature\.fileUpload\.title/)).not.toBeInTheDocument()
expect(screen.queryByText(/feature\.imageUpload\.title/)).not.toBeInTheDocument()
})
it('should show file upload tip in chat mode with showFileUpload', () => {
renderPanel({ isChatMode: true, showFileUpload: true })
expect(screen.getByText(/common\.fileUploadTip/)).toBeInTheDocument()
})
it('should show image upload legacy tip in non-chat mode with showFileUpload', () => {
renderPanel({ isChatMode: false, showFileUpload: true })
expect(screen.getByText(/common\.ImageUploadLegacyTip/)).toBeInTheDocument()
})
})
describe('MoreLikeThis Feature', () => {
@@ -204,12 +192,4 @@ describe('NewFeaturePanel', () => {
expect(screen.queryByText(/feature\.annotation\.title/)).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should not show file upload tip when showFileUpload is false', () => {
renderPanel({ isChatMode: true, showFileUpload: false })
expect(screen.queryByText(/common\.fileUploadTip/)).not.toBeInTheDocument()
})
})
})

View File

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

View File

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

View File

@@ -3,8 +3,9 @@ import type { InputVar } from '@/app/components/workflow/types'
import type { PromptVariable } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
@@ -13,7 +14,6 @@ import { ReactSortable } from 'react-sortablejs'
import ConfirmAddVar from '@/app/components/app/configuration/config-prompt/confirm-add-var'
import { getInputKeys } from '@/app/components/base/block-input'
import Divider from '@/app/components/base/divider'
import Modal from '@/app/components/base/modal'
import PromptEditor from '@/app/components/base/prompt-editor'
import { checkKeys, getNewVar } from '@/utils/var'
@@ -39,6 +39,7 @@ const OpeningSettingModal = ({
const { t } = useTranslation()
const [tempValue, setTempValue] = useState(data?.opening_statement || '')
useEffect(() => {
// eslint-disable-next-line react/set-state-in-effect
setTempValue(data.opening_statement || '')
}, [data.opening_statement])
const [tempSuggestedQuestions, setTempSuggestedQuestions] = useState(data.suggested_questions || [])
@@ -99,22 +100,49 @@ const OpeningSettingModal = ({
const [focusID, setFocusID] = useState<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 = () => {
return (
<div>
<div className="flex items-center py-2">
<div className="flex shrink-0 space-x-0.5 text-xs leading-[18px] font-medium text-text-tertiary">
<div className="uppercase">{t('openingStatement.openingQuestion', { ns: 'appDebug' })}</div>
<div>·</div>
<div>
{tempSuggestedQuestions.length}
/
{MAX_QUESTION_NUM}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-1">
<div className="text-sm font-medium text-text-primary">
{t('openingStatement.openingQuestion', { ns: 'appDebug' })}
</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>
<Divider bgStyle="gradient" className="ml-3 h-px w-0 grow" />
</div>
<Divider bgStyle="gradient" className="mb-3 h-px" />
<ReactSortable
className="space-y-1"
list={tempSuggestedQuestions.map((name, index) => {
@@ -133,8 +161,8 @@ const OpeningSettingModal = ({
<div
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',
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',
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}
>
@@ -152,8 +180,13 @@ const OpeningSettingModal = ({
return item
}))
}}
autoFocus={autoFocusQuestionID === index}
className="h-9 w-full grow cursor-pointer overflow-x-auto rounded-lg border-0 bg-transparent pr-8 pl-1.5 text-sm leading-9 text-text-secondary focus:outline-hidden"
onFocus={() => setFocusID(index)}
onFocus={() => {
setFocusID(index)
if (autoFocusQuestionID === index)
setAutoFocusQuestionID(null)
}}
onBlur={() => setFocusID(null)}
/>
@@ -173,7 +206,12 @@ const OpeningSettingModal = ({
</ReactSortable>
{tempSuggestedQuestions.length < MAX_QUESTION_NUM && (
<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"
>
<span className="i-ri-add-line h-4 w-4" />
@@ -185,81 +223,90 @@ const OpeningSettingModal = ({
}
return (
<Modal
isShow
onClose={noop}
className="mt-14! w-[640px]! max-w-none! bg-components-panel-bg-blur! p-6!"
>
<div className="mb-6 flex items-center justify-between">
<div className="title-2xl-semi-bold text-text-primary">{t('feature.conversationOpener.title', { ns: 'appDebug' })}</div>
<div
className="cursor-pointer p-1"
onClick={onCancel}
data-testid="close-modal"
role="button"
tabIndex={0}
onKeyDown={(e) => {
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,
})),
],
<Dialog open onOpenChange={open => !open && onCancel()} disablePointerDismissal>
<DialogContent className="mt-14 w-[640px] max-w-none rounded-2xl bg-components-panel-bg-blur p-6">
<div className="mb-6 flex items-center justify-between">
<div className="title-2xl-semi-bold text-text-primary">{t('feature.conversationOpener.title', { ns: 'appDebug' })}</div>
<div
className="cursor-pointer p-1"
onClick={onCancel}
data-testid="close-modal"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onCancel()
}
}}
/>
{renderQuestions()}
>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</div>
</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}
/>
)}
</Modal>
<div className="mb-8 space-y-4">
<div
data-testid="opener-input-section"
className="py-2"
>
<div className="mb-3 text-sm font-medium text-text-primary">
{t('openingStatement.editorTitle', { ns: 'appDebug' })}
</div>
<div className="relative min-h-[80px] rounded-lg bg-components-input-bg-normal px-3 py-2">
<PromptEditor
value={tempValue}
onChange={setTempValue}
placeholder={openerPlaceholder}
placeholderClassName="!overflow-visible !whitespace-pre-wrap !text-clip break-words pr-8"
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,
})),
],
}}
/>
</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 { InputVar } from '@/app/components/workflow/types'
import type { PromptVariable } from '@/models/debug'
import { RiCloseLine, RiInformation2Fill } from '@remixicon/react'
import { RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply'
@@ -64,19 +64,6 @@ const NewFeaturePanel = ({
</div>
{/* list */}
<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 && (
<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 { SystemFeatures } from '@/types/feature'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import {
@@ -12,12 +13,19 @@ import {
useAppContext,
userProfilePlaceholder,
} from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { defaultSystemFeatures } from '@/types/feature'
import CustomPage from '../index'
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: {
branding: {
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
},
},
})
const { mockToast } = vi.hoisted(() => {
const mockToast = Object.assign(vi.fn(), {
success: vi.fn(),
@@ -44,9 +52,6 @@ vi.mock('@/context/app-context', async (importOriginal) => {
useAppContext: vi.fn(),
}
})
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: mockToast,
}))
@@ -54,7 +59,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseModalContext = vi.mocked(useModalContext)
const mockUseAppContext = vi.mocked(useAppContext)
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const createProviderContext = ({
enableBilling = false,
@@ -93,15 +97,6 @@ const createAppContextValue = (): AppContextValue => ({
isValidatingCurrentWorkspace: false,
})
const createSystemFeatures = (): SystemFeatures => ({
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
},
})
describe('CustomPage', () => {
const setShowPricingModal = vi.fn()
@@ -113,10 +108,6 @@ describe('CustomPage', () => {
setShowPricingModal,
} as unknown as ReturnType<typeof useModalContext>)
mockUseAppContext.mockReturnValue(createAppContextValue())
mockUseGlobalPublicStore.mockImplementation(selector => selector({
systemFeatures: createSystemFeatures(),
setSystemFeatures: vi.fn(),
}))
})
// Integration coverage for the page and its child custom brand section.

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import type { AppCardProps } from '../index'
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 { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import { trackEvent } from '@/app/components/base/amplitude'
import { AppModeEnum } from '@/types/app'
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 { cn } from '@langgenius/dify-ui/cn'
import { RiInformation2Line } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
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 { AppTypeIcon } from '../../app/type-selector'
@@ -29,7 +30,7 @@ const AppCard = ({
}: AppCardProps) => {
const { t } = useTranslation()
const { app: appBasicInfo } = app
const { systemFeatures } = useGlobalPublicStore()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
const handleTryApp = () => {
trackEvent('preview_template', {

View File

@@ -1,9 +1,10 @@
import type { ReactNode } from 'react'
import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/models/explore'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { fetchAppDetail } from '@/service/explore'
import { useMembers } from '@/service/use-common'
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)
return renderWithNuqs(
<AppList onSuccess={onSuccess} />,
const { wrapper: SystemFeaturesWrapper, queryClient } = createSystemFeaturesWrapper({
systemFeatures: { enable_explore_banner: options.enableExploreBanner ?? false },
})
const Wrapped = ({ children }: { children: ReactNode }) => (
<SystemFeaturesWrapper>{children}</SystemFeaturesWrapper>
)
const rendered = renderWithNuqs(
<Wrapped><AppList onSuccess={onSuccess} /></Wrapped>,
{ searchParams },
)
return { ...rendered, queryClient }
}
describe('AppList', () => {
@@ -435,18 +452,12 @@ describe('AppList', () => {
describe('Banner', () => {
it('should render banner when enable_explore_banner is true', () => {
useGlobalPublicStore.setState({
systemFeatures: {
...useGlobalPublicStore.getState().systemFeatures,
enable_explore_banner: true,
},
})
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
renderAppList()
renderAppList(false, undefined, undefined, { enableExploreBanner: true })
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 { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useQueryState } from 'nuqs'
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 CreateAppModal from '@/app/components/explore/create-app-modal'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useImportDSL } from '@/hooks/use-import-dsl'
import {
DSLImportMode,
} from '@/models/app'
import { fetchAppDetail } from '@/service/explore'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useMembers } from '@/service/use-common'
import { useExploreAppList } from '@/service/use-explore'
import { trackCreateApp } from '@/utils/create-app-tracking'
@@ -39,7 +40,7 @@ const Apps = ({
}: AppsProps) => {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const { systemFeatures } = useGlobalPublicStore()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: membersData } = useMembers()
const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' })
const userAccount = membersData?.accounts?.find(account => account.id === userProfile.id)

View File

@@ -1,6 +1,7 @@
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 { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import TryApp from '../index'
import { TypeEnum } from '../tab'

View File

@@ -3,13 +3,14 @@
import type { FC } from 'react'
import type { App as AppType } from '@/models/explore'
import { Button } from '@langgenius/dify-ui/button'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useState } from 'react'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal/index'
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 App from './app'
import AppInfo from './app-info'
@@ -31,7 +32,7 @@ const TryApp: FC<Props> = ({
onClose,
onCreate,
}) => {
const { systemFeatures } = useGlobalPublicStore()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app)
const canUseTryTab = IS_CLOUD_EDITION && (app ? isTrialApp : true)
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 { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import Header from '../index'
function createMockComponent(testId: string) {
@@ -93,21 +95,16 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
vi.mock('@/context/global-public-context', () => {
type SystemFeatures = { branding: { enabled: boolean, application_title: string | null, workspace_logo: string | null } }
return {
useGlobalPublicStore: (selector: (s: { systemFeatures: SystemFeatures }) => SystemFeatures) =>
selector({
systemFeatures: {
branding: {
enabled: mockBrandingEnabled,
application_title: mockBrandingTitle,
workspace_logo: mockBrandingLogo,
},
},
}),
}
})
const renderHeader = (ui: ReactElement = <Header />) =>
renderWithSystemFeatures(ui, {
systemFeatures: {
branding: {
enabled: mockBrandingEnabled,
application_title: mockBrandingTitle ?? '',
workspace_logo: mockBrandingLogo ?? '',
},
},
})
describe('Header', () => {
beforeEach(() => {
@@ -123,7 +120,7 @@ describe('Header', () => {
})
it('should render header with main nav components', () => {
render(<Header />)
renderHeader()
expect(screen.getByRole('img', { name: /dify logo/i })).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', () => {
mockEnableBilling = false
const { rerender } = render(<Header />)
const { rerender } = renderHeader()
expect(screen.getByTestId('license-nav')).toBeInTheDocument()
expect(screen.queryByTestId('plan-badge')).not.toBeInTheDocument()
@@ -145,7 +142,7 @@ describe('Header', () => {
it('should hide explore nav when user is dataset operator', () => {
mockIsDatasetOperator = true
render(<Header />)
renderHeader()
expect(screen.queryByTestId('explore-nav')).not.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', () => {
mockEnableBilling = true
mockPlanType = 'sandbox'
const { rerender } = render(<Header />)
const { rerender } = renderHeader()
fireEvent.click(screen.getByTestId('plan-badge'))
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
@@ -167,7 +164,7 @@ describe('Header', () => {
it('should render mobile layout without env nav', () => {
mockMedia = 'mobile'
render(<Header />)
renderHeader()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.queryByTestId('env-nav')).not.toBeInTheDocument()
@@ -178,7 +175,7 @@ describe('Header', () => {
mockBrandingTitle = 'Acme Workspace'
mockBrandingLogo = '/logo.png'
render(<Header />)
renderHeader()
expect(screen.getByText('Acme Workspace')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /logo/i })).toBeInTheDocument()
@@ -190,7 +187,7 @@ describe('Header', () => {
mockBrandingTitle = 'Custom Title'
mockBrandingLogo = null
render(<Header />)
renderHeader()
expect(screen.getByText('Custom Title')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
@@ -201,7 +198,7 @@ describe('Header', () => {
mockBrandingTitle = null
mockBrandingLogo = null
render(<Header />)
renderHeader()
expect(screen.getByText('Dify')).toBeInTheDocument()
})
@@ -210,7 +207,7 @@ describe('Header', () => {
mockIsWorkspaceEditor = true
mockIsDatasetOperator = false
render(<Header />)
renderHeader()
expect(screen.getByTestId('dataset-nav')).toBeInTheDocument()
expect(screen.getByTestId('explore-nav')).toBeInTheDocument()
@@ -221,7 +218,7 @@ describe('Header', () => {
mockIsWorkspaceEditor = false
mockIsDatasetOperator = false
render(<Header />)
renderHeader()
expect(screen.queryByTestId('dataset-nav')).not.toBeInTheDocument()
})
@@ -230,7 +227,7 @@ describe('Header', () => {
mockMedia = 'mobile'
mockIsDatasetOperator = true
render(<Header />)
renderHeader()
expect(screen.queryByTestId('explore-nav')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-nav')).not.toBeInTheDocument()
@@ -243,7 +240,7 @@ describe('Header', () => {
mockEnableBilling = true
mockPlanType = 'sandbox'
render(<Header />)
renderHeader()
expect(screen.getByTestId('plan-badge')).toBeInTheDocument()
expect(screen.queryByTestId('license-nav')).not.toBeInTheDocument()

View File

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

View File

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

View File

@@ -1,17 +1,23 @@
import type { AppContextValue } from '@/context/app-context'
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { SystemFeatures } from '@/types/feature'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
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', () => ({
default: () => <div data-testid="account-setting">AccountSetting</div>,
}))
@@ -37,10 +43,6 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
@@ -79,15 +81,19 @@ const { mockConfig, mockEnv } = vi.hoisted(() => ({
},
},
}))
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
get AMPLITUDE_API_KEY() { return mockConfig.AMPLITUDE_API_KEY },
get isAmplitudeEnabled() { return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY },
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('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
get AMPLITUDE_API_KEY() { return mockConfig.AMPLITUDE_API_KEY },
get isAmplitudeEnabled() { return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY },
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)
const baseAppContextValue: AppContextValue = {
@@ -136,20 +142,13 @@ describe('AccountDropdown', () => {
const mockLogout = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const renderWithRouter = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
const renderWithRouter = (
ui: React.ReactElement,
options: { systemFeatures?: DeepPartial<SystemFeatures> } = {},
) => {
return renderWithSystemFeatures(ui, {
systemFeatures: options.systemFeatures ?? { branding: { enabled: false } },
})
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
beforeEach(() => {
@@ -159,10 +158,6 @@ describe('AccountDropdown', () => {
mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'show'
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({
isEducationAccount: false,
plan: { type: Plan.sandbox },
@@ -316,14 +311,10 @@ describe('AccountDropdown', () => {
describe('Branding and Environment', () => {
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
renderWithRouter(<AppSelector />)
renderWithRouter(<AppSelector />, {
systemFeatures: { branding: { enabled: true } },
})
fireEvent.click(screen.getByRole('button'))
// Assert

View File

@@ -4,6 +4,7 @@ import type { MouseEventHandler, ReactNode } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
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 { useTranslation } from 'react-i18next'
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 { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { env } from '@/env'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useLogout } from '@/service/use-common'
import AccountAbout from '../account-about'
import GithubStar from '../github-star'
@@ -110,7 +111,7 @@ export default function AppSelector() {
const router = useRouter()
const [aboutVisible, setAboutVisible] = useState(false)
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const { systemFeatures } = useGlobalPublicStore()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { t } = useTranslation()
const docLink = useDocLink()

View File

@@ -1,8 +1,8 @@
import type { AccountSettingTab } from '../constants'
import type { AppContextValue } from '@/context/app-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { useState } from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@@ -47,36 +47,6 @@ vi.mock('@/hooks/use-breakpoints', () => ({
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', () => ({
useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })),
useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })),
@@ -176,11 +146,14 @@ describe('AccountSetting', () => {
)
}
return render(
<QueryClientProvider client={new QueryClient()}>
<StatefulAccountSetting />
</QueryClientProvider>,
)
return renderWithSystemFeatures(<StatefulAccountSetting />, {
systemFeatures: {
webapp_auth: { enabled: true },
branding: { enabled: false },
enable_marketplace: true,
enable_collaboration_mode: false,
},
})
}
beforeEach(() => {

View File

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

View File

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

View File

@@ -1,35 +1,34 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWorkspacePermissions } from '@/service/use-workspace'
import InviteButton from '../invite-button'
vi.mock('@/context/app-context')
vi.mock('@/context/global-public-context')
vi.mock('@/service/use-workspace')
describe('InviteButton', () => {
const setupMocks = ({
brandingEnabled,
const setupPermissions = ({
isFetching,
allowInvite,
}: {
brandingEnabled: boolean
isFetching: boolean
allowInvite?: boolean
}) => {
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { branding: { enabled: brandingEnabled } },
} as unknown as Parameters<typeof selector>[0]))
vi.mocked(useWorkspacePermissions).mockReturnValue({
data: allowInvite === undefined ? null : { allow_member_invite: allowInvite },
isFetching,
} as unknown as ReturnType<typeof useWorkspacePermissions>)
}
const renderInviteButton = (brandingEnabled: boolean) =>
renderWithSystemFeatures(<InviteButton />, {
systemFeatures: { branding: { enabled: brandingEnabled } },
})
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
@@ -38,33 +37,33 @@ describe('InviteButton', () => {
})
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()
})
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()
})
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()
})
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()
})

View File

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

View File

@@ -1,9 +1,10 @@
import { Button } from '@langgenius/dify-ui/button'
import { RiUserAddLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
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'
type InviteButtonProps = {
@@ -14,7 +15,7 @@ type InviteButtonProps = {
const InviteButton = (props: InviteButtonProps) => {
const { t } = useTranslation()
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)
if (systemFeatures.branding.enabled) {
if (isFetchingWorkspacePermissions) {

View File

@@ -1,36 +1,38 @@
import type { AppContextValue } from '@/context/app-context'
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 { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWorkspacePermissions } from '@/service/use-workspace'
import TransferOwnership from '../transfer-ownership'
vi.mock('@/context/app-context')
vi.mock('@/context/global-public-context')
vi.mock('@/service/use-workspace')
describe('TransferOwnership', () => {
const setupMocks = ({
brandingEnabled,
const setupPermissions = ({
isFetching,
allowOwnerTransfer,
}: {
brandingEnabled: boolean
isFetching: boolean
allowOwnerTransfer?: boolean
}) => {
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { branding: { enabled: brandingEnabled } },
} as unknown as Parameters<typeof selector>[0]))
vi.mocked(useWorkspacePermissions).mockReturnValue({
data: allowOwnerTransfer === undefined ? null : { allow_owner_transfer: allowOwnerTransfer },
isFetching,
} as unknown as ReturnType<typeof useWorkspacePermissions>)
}
const renderTransferOwnership = (
brandingEnabled: boolean,
onOperate: () => void = vi.fn(),
) =>
renderWithSystemFeatures(<TransferOwnership onOperate={onOperate} />, {
systemFeatures: { branding: { enabled: brandingEnabled } },
})
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
@@ -39,17 +41,17 @@ describe('TransferOwnership', () => {
})
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()
})
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.queryByText(/members\.transferOwnership/i)).toBeNull()
@@ -59,9 +61,9 @@ describe('TransferOwnership', () => {
const user = userEvent.setup()
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 }))
const transferOptionText = await screen.findByText(/members\.transferOwnership/i)
@@ -78,9 +80,9 @@ describe('TransferOwnership', () => {
it('should allow transfer menu when branding is disabled', async () => {
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 }))

View File

@@ -4,11 +4,12 @@ import { cn } from '@langgenius/dify-ui/cn'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
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'
type Props = {
@@ -18,7 +19,7 @@ type Props = {
const TransferOwnership = ({ onOperate }: Props) => {
const { t } = useTranslation()
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)
if (systemFeatures.branding.enabled) {
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 {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
@@ -15,17 +16,13 @@ const mockQuotaConfig = {
is_valid: true,
}
vi.mock('@/config', () => ({
IS_CLOUD_EDITION: false,
}))
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: {
enable_marketplace: false,
},
}),
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CLOUD_EDITION: false,
}
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
@@ -62,26 +59,41 @@ vi.mock('../install-from-marketplace', () => ({
default: () => <div data-testid="install-from-marketplace" />,
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const originalPlugins = actual.consoleQuery.plugins as unknown as Record<string, unknown>
return {
...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', () => {
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.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 { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
@@ -7,8 +8,6 @@ import {
} from '../declarations'
import ModelProviderPage from '../index'
let mockEnableMarketplace = true
const mockQuotaConfig = {
quota_type: CurrentSystemQuotaTypeEnum.free,
quota_unit: QuotaUnitEnum.times,
@@ -18,13 +17,14 @@ const mockQuotaConfig = {
is_valid: true,
}
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: {
enable_marketplace: mockEnableMarketplace,
},
}),
}))
const renderModelProviderPage = (
props: { searchText?: string, enableMarketplace?: boolean } = {},
) => {
const { searchText = '', enableMarketplace = true } = props
return renderWithSystemFeatures(<ModelProviderPage searchText={searchText} />, {
systemFeatures: { enable_marketplace: enableMarketplace },
})
}
const mockProviders = [
{
@@ -83,28 +83,40 @@ vi.mock('../system-model-selector', () => ({
default: () => <div data-testid="system-model-selector" />,
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const originalPlugins = actual.consoleQuery.plugins as unknown as Record<string, unknown>
return {
...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', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockEnableMarketplace = true
Object.keys(mockDefaultModels).forEach((key) => {
mockDefaultModels[key] = { data: null, isLoading: false }
})
@@ -134,21 +146,21 @@ describe('ModelProviderPage', () => {
})
it('should render main elements', () => {
render(<ModelProviderPage searchText="" />)
renderModelProviderPage()
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument()
})
it('should render configured and not configured providers sections', () => {
render(<ModelProviderPage searchText="" />)
renderModelProviderPage()
expect(screen.getByText('openai')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
expect(screen.getByText('anthropic')).toBeInTheDocument()
})
it('should filter providers based on search text', () => {
render(<ModelProviderPage searchText="open" />)
renderModelProviderPage({ searchText: 'open' })
act(() => {
vi.advanceTimersByTime(600)
})
@@ -157,7 +169,7 @@ describe('ModelProviderPage', () => {
})
it('should show empty state if no configured providers match', () => {
render(<ModelProviderPage searchText="non-existent" />)
renderModelProviderPage({ searchText: 'non-existent' })
act(() => {
vi.advanceTimersByTime(600)
})
@@ -165,9 +177,7 @@ describe('ModelProviderPage', () => {
})
it('should hide marketplace section when marketplace feature is disabled', () => {
mockEnableMarketplace = false
render(<ModelProviderPage searchText="" />)
renderModelProviderPage({ enableMarketplace: false })
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.notConfigured')).not.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument()
})
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()
})
@@ -202,7 +212,7 @@ describe('ModelProviderPage', () => {
isLoading: false,
}
render(<ModelProviderPage searchText="" />)
renderModelProviderPage()
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
})
@@ -217,7 +227,7 @@ describe('ModelProviderPage', () => {
mockDefaultModels.speech2text = makeModel('whisper-1', 'speech2text')
mockDefaultModels.tts = makeModel('tts-1', 'tts')
render(<ModelProviderPage searchText="" />)
renderModelProviderPage()
expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
@@ -228,7 +238,7 @@ describe('ModelProviderPage', () => {
mockDefaultModels[key] = { data: null, isLoading: true }
})
render(<ModelProviderPage searchText="" />)
renderModelProviderPage()
expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.noneConfigured')).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)
expect(renderedProviders).toEqual([

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