From f95edc85f581904adda332adbec38110516d78cf Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:35:06 +0000 Subject: [PATCH] refactor(web): consume generated app detail contracts (#42982) --- oxlint-suppressions.json | 8 +- .../[appId]/__tests__/layout-main.spec.tsx | 91 +++-- .../(appDetailLayout)/[appId]/layout-main.tsx | 12 +- .../__tests__/app-detail-section.spec.tsx | 14 +- .../__tests__/app-info-identity.spec.tsx | 9 +- .../__tests__/app-info-modals.spec.tsx | 9 +- .../__tests__/app-info-trigger.spec.tsx | 9 +- .../__tests__/use-app-info-actions.spec.ts | 217 +++++----- .../app-sidebar/app-info/app-info-modals.tsx | 8 +- .../app-sidebar/app-info/app-info-trigger.tsx | 6 +- .../app-info/use-app-info-actions.ts | 28 +- .../access-point/__tests__/mcp-card.spec.tsx | 17 +- .../__tests__/service-api-card.spec.tsx | 14 +- .../__tests__/trigger-card.spec.tsx | 6 +- .../use-access-point-actions.spec.ts | 39 +- .../__tests__/web-app-card.spec.tsx | 49 ++- .../built-in-access-points/mcp-card.tsx | 4 +- .../service-api-card.tsx | 4 +- .../built-in-access-points/trigger-card.tsx | 4 +- .../built-in-access-points/web-app-card.tsx | 70 ++-- .../environment-web-app-card.tsx | 8 +- .../shared/service-api-card-view.tsx | 4 +- .../shared/use-access-point-actions.ts | 20 +- .../app/access-point/shared/utils.ts | 26 +- .../app/annotation/__tests__/index.spec.tsx | 6 +- web/app/components/app/annotation/index.tsx | 5 +- .../__tests__/index.spec.tsx | 15 +- .../app-publisher/__tests__/sections.spec.tsx | 6 - .../built-in-publisher/actions-section.tsx | 14 +- .../app-publisher/publisher-content/index.tsx | 2 +- .../use-publish-controller.ts | 3 +- .../publisher-content/use-version-info.ts | 5 +- .../publisher-content/use-workflow-tool.ts | 5 +- web/app/components/app/app-publisher/utils.ts | 5 +- .../__tests__/configuration-view.spec.tsx | 7 - .../app/configuration/__tests__/utils.spec.ts | 7 - .../__tests__/index-logic.spec.tsx | 6 +- .../config-modal/__tests__/index.spec.tsx | 6 +- .../config/__tests__/index.spec.tsx | 7 - .../agent-tools/__tests__/index.spec.tsx | 7 - .../configuration-view/index.tsx | 1 + .../debug/__tests__/hooks.spec.tsx | 7 - .../debug/__tests__/index.spec.tsx | 50 ++- .../__tests__/index.spec.tsx | 7 - .../__tests__/text-generation-item.spec.tsx | 8 - .../text-generation-item.tsx | 1 - .../__tests__/index.spec.tsx | 7 - .../app/configuration/debug/hooks.tsx | 1 - .../app/configuration/debug/index.tsx | 93 +++-- .../__tests__/use-configuration-utils.spec.ts | 295 ++++++++++---- .../__tests__/use-configuration.spec.tsx | 168 +++++--- .../hooks/configuration-lifecycle/dataset.ts | 73 +++- .../hooks/configuration-lifecycle/load.ts | 81 ++-- .../configuration-lifecycle/prompt-config.ts | 35 +- .../hooks/configuration-lifecycle/publish.ts | 163 +++++--- .../published-config.ts | 171 ++++---- .../hooks/configuration-lifecycle/types.ts | 7 +- .../use-configuration-app-context.ts | 19 +- .../use-configuration-loader.ts | 15 +- .../use-configuration-publish.ts | 17 +- .../use-model-configuration-state.ts | 7 - .../external-data-tool-modal-utils.spec.ts | 10 +- .../tools/external-data-tool-modal-utils.ts | 11 +- .../create-app-modal/__tests__/index.spec.tsx | 33 +- .../app/deploy/__tests__/index.spec.tsx | 50 ++- .../built-in-environment-card/index.tsx | 2 +- .../duplicate-modal/__tests__/index.spec.tsx | 24 ++ .../components/app/duplicate-modal/index.tsx | 57 +-- .../log-annotation/__tests__/index.spec.tsx | 46 +-- .../app/log/__tests__/empty-element.spec.tsx | 25 +- web/app/components/app/log/empty-element.tsx | 11 +- web/app/components/app/log/index.tsx | 5 +- web/app/components/app/log/list.tsx | 9 +- .../app/overview/customize/index.tsx | 3 +- .../app/overview/embedded/index.tsx | 4 +- .../settings/__tests__/index.spec.tsx | 24 ++ .../app/overview/settings/index.tsx | 93 +++-- web/app/components/app/store.ts | 6 +- .../workflow-log/__tests__/detail.spec.tsx | 41 +- .../app/workflow-log/__tests__/index.spec.tsx | 41 +- .../app/workflow-log/__tests__/list.spec.tsx | 44 +- web/app/components/app/workflow-log/index.tsx | 5 +- web/app/components/app/workflow-log/list.tsx | 5 +- web/app/components/base/chat/types.ts | 6 +- .../follow-up-setting-modal.tsx | 11 +- ...deration-setting-modal-submission.spec.tsx | 191 +++++++++ .../moderation-setting-modal.spec.tsx | 146 +++++++ .../moderation/form-generation.tsx | 14 +- .../moderation/moderation-setting-modal.tsx | 381 +++++++++--------- web/app/components/base/features/types.ts | 5 +- .../components/base/text-generation/types.ts | 7 +- .../create-app-modal/__tests__/index.spec.tsx | 31 +- .../explore/create-app-modal/index.tsx | 81 ++-- .../explore/try-app/app/text-generation.tsx | 2 +- .../__tests__/app-inputs-form.spec.tsx | 258 +++++++++--- .../app-selector/app-inputs-form.tsx | 22 +- .../hooks/use-text-generation-app-state.ts | 2 +- .../__tests__/features-trigger.spec.tsx | 17 +- .../workflow-header/features-trigger.tsx | 5 +- .../hooks/use-workflow-run-utils.ts | 3 +- .../__tests__/test-email-sender.spec.tsx | 7 +- .../condition-list/condition-item.tsx | 7 +- .../nodes/knowledge-retrieval/types.ts | 2 +- .../chat-record/__tests__/index.spec.tsx | 4 +- .../panel/version-history-panel/index.tsx | 4 +- .../workflow/update-dsl-modal.helpers.ts | 6 +- .../__tests__/apply.spec.ts | 38 +- .../workflow/workflow-generator/apply.ts | 30 +- web/context/debug-configuration.ts | 7 - .../access/components/web-app-access-card.tsx | 8 +- web/hooks/use-import-dsl.spec.tsx | 22 + web/hooks/use-import-dsl.ts | 6 +- web/models/app.ts | 5 - web/models/common.ts | 5 +- web/models/debug.ts | 23 +- web/service/apps.ts | 108 +---- web/service/try-app.ts | 2 +- web/test/fixtures/app.ts | 33 ++ web/types/app.ts | 136 +------ web/types/workflow.ts | 7 +- web/utils/model-config.spec.ts | 12 + web/utils/model-config.ts | 42 +- 122 files changed, 2521 insertions(+), 1732 deletions(-) create mode 100644 web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal-submission.spec.tsx diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index fb53b55192d..65af5626f5a 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -435,7 +435,7 @@ "count": 1 }, "typescript/no-explicit-any": { - "count": 11 + "count": 4 } }, "web/app/components/app/configuration/debug/types.ts": { @@ -450,7 +450,7 @@ }, "web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts": { "typescript/no-explicit-any": { - "count": 52 + "count": 48 } }, "web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx": { @@ -4286,7 +4286,7 @@ }, "web/models/debug.ts": { "typescript/no-explicit-any": { - "count": 4 + "count": 3 } }, "web/models/log.ts": { @@ -4381,7 +4381,7 @@ "count": 1 }, "typescript/no-explicit-any": { - "count": 3 + "count": 1 } }, "web/service/base.spec.ts": { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index f1b7601641e..13641a5fe0d 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -1,13 +1,14 @@ -import type { App } from '@/types/app' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import { act, screen, waitFor } from '@testing-library/react' import { useStore } from '@/app/components/app/store' -import { fetchAppDetailDirect } from '@/service/apps' import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import AppDetailLayout from '../layout-main' const mockReplace = vi.fn() +const mockAppResponse = vi.fn() let mockPathname = '/app/app-1/workflow' let mockIsRbacEnabled = true const mockConsoleState = vi.hoisted(() => ({ @@ -31,8 +32,12 @@ const render = (ui: Parameters[0]) => vi.mock('@/next/navigation', () => mockNavigation) -vi.mock('@/service/apps', () => ({ - fetchAppDetailDirect: vi.fn(), +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + request: async (url: string) => { + const response = await mockAppResponse(url) + return response instanceof Response ? response : Response.json(response) + }, })) vi.mock('@/context/workspace-state', async () => { @@ -46,20 +51,15 @@ vi.mock('@/context/permission-state', async () => { const mockUsePathname = mockNavigation.usePathname const mockUseRouter = mockNavigation.useRouter -const mockFetchAppDetailDirect = vi.mocked(fetchAppDetailDirect) -type AppDetailFixture = App & { - bound_agent_id?: string | null -} - -const createAppDetail = (overrides: Partial = {}) => - ({ +const createAppDetail = (overrides: Partial = {}) => + createAppDetailFixture({ id: 'app-1', name: 'Demo App', mode: AppModeEnum.WORKFLOW, permission_keys: [AppACLPermission.ViewLayout, AppACLPermission.Monitor], ...overrides, - }) as AppDetailFixture + }) const waitForAppContent = async () => { await waitFor(() => { @@ -82,7 +82,7 @@ describe('AppDetailLayout', () => { mockUseRouter.mockReturnValue({ replace: mockReplace, }) - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail()) + mockAppResponse.mockResolvedValue(createAppDetail()) useStore.getState().setAppDetail() }) @@ -98,7 +98,7 @@ describe('AppDetailLayout', () => { ['/app/app-1/access-config', 'navigation.settings.resourceAccess', AppModeEnum.WORKFLOW], ])('identifies the current detail page for %s', async (pathname, pageTitle, mode) => { mockPathname = pathname - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ mode, permission_keys: Object.values(AppACLPermission), @@ -134,10 +134,24 @@ describe('AppDetailLayout', () => { await waitFor(() => { expect(document.title).toBe('common.appMenus.promptEng ยท Renamed App - Dify') }) - expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1) + expect(mockAppResponse).toHaveBeenCalledTimes(1) }) }) + it('redirects a deleted app after the generated transport returns not found', async () => { + mockAppResponse.mockResolvedValue(Response.json({ message: 'App not found' }, { status: 404 })) + + render( + +
App page content
+
, + ) + + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/apps')) + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + expect(useStore.getState().appDetail).toBeUndefined() + }) + it('should keep app detail data when navigating between pages in the same app', async () => { const { rerender, unmount } = render( @@ -145,7 +159,8 @@ describe('AppDetailLayout', () => { , ) await waitForAppContent() - expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1) + expect(mockAppResponse).toHaveBeenCalledWith(expect.stringMatching(/\/apps\/app-1$/)) + expect(mockAppResponse).toHaveBeenCalledTimes(1) mockPathname = '/app/app-1/logs' rerender( @@ -155,7 +170,7 @@ describe('AppDetailLayout', () => { ) await waitForAppContent() - expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1) + expect(mockAppResponse).toHaveBeenCalledTimes(1) expect(useStore.getState().appDetail?.id).toBe('app-1') unmount() @@ -166,7 +181,7 @@ describe('AppDetailLayout', () => { ) await waitForAppContent() - expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1) + expect(mockAppResponse).toHaveBeenCalledTimes(1) expect(useStore.getState().appDetail?.id).toBe('app-1') }) @@ -184,7 +199,7 @@ describe('AppDetailLayout', () => { it('should redirect restricted app pages before exposing app detail content', async () => { mockPathname = '/app/app-1/logs' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }), ) @@ -203,7 +218,7 @@ describe('AppDetailLayout', () => { it('should redirect logs pages when log and annotation access is missing', async () => { mockPathname = '/app/app-1/logs' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ permission_keys: [AppACLPermission.Monitor] }), ) @@ -222,7 +237,7 @@ describe('AppDetailLayout', () => { it('should allow users with log and annotation access to open logs directly', async () => { mockPathname = '/app/app-1/logs' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ permission_keys: [AppACLPermission.LogAndAnnotation] }), ) @@ -240,7 +255,7 @@ describe('AppDetailLayout', () => { it('should allow users with Access Point view permission to open the page directly', async () => { mockPathname = '/app/app-1/access-point' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ permission_keys: [AppACLPermission.AccessPointView] }), ) @@ -258,7 +273,7 @@ describe('AppDetailLayout', () => { it('should redirect access point pages when view permission is missing', async () => { mockPathname = '/app/app-1/access-point' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] })) + mockAppResponse.mockResolvedValue(createAppDetail({ permission_keys: [] })) render( @@ -287,12 +302,12 @@ describe('AppDetailLayout', () => { await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith('/apps') }) - expect(mockFetchAppDetailDirect).not.toHaveBeenCalled() + expect(mockAppResponse).not.toHaveBeenCalled() }) it('should redirect deploy pages when app deploy ACL permission is missing', async () => { mockPathname = '/app/app-1/deploy' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }), ) @@ -311,7 +326,7 @@ describe('AppDetailLayout', () => { it('should allow users with app deploy ACL permission to open deploy directly', async () => { mockPathname = '/app/app-1/deploy' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ permission_keys: [AppACLPermission.Deploy] }), ) @@ -344,7 +359,7 @@ describe('AppDetailLayout', () => { it('should redirect workflow pages when layout access is missing', async () => { mockPathname = '/app/app-1/workflow' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] })) + mockAppResponse.mockResolvedValue(createAppDetail({ permission_keys: [] })) render( @@ -361,7 +376,7 @@ describe('AppDetailLayout', () => { it('should redirect overview pages when monitor access is missing', async () => { mockPathname = '/app/app-1/overview' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }), ) @@ -381,7 +396,7 @@ describe('AppDetailLayout', () => { it('should wait for workspace permission keys before redirecting restricted pages', async () => { mockConsoleState.isLoadingWorkspacePermissionKeys = true mockPathname = '/app/app-1/overview' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }), ) @@ -392,7 +407,7 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1) + expect(mockAppResponse).toHaveBeenCalledTimes(1) }) expect(mockReplace).not.toHaveBeenCalled() expect(screen.queryByText('App page content')).not.toBeInTheDocument() @@ -411,7 +426,7 @@ describe('AppDetailLayout', () => { it('should allow users with monitor access to open overview directly', async () => { mockPathname = '/app/app-1/overview' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ permission_keys: [AppACLPermission.Monitor] }), ) @@ -429,7 +444,7 @@ describe('AppDetailLayout', () => { it('should redirect access config pages when access config access is missing', async () => { mockPathname = '/app/app-1/access-config' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }), ) @@ -448,7 +463,7 @@ describe('AppDetailLayout', () => { it('should allow users with access config access to open access config directly', async () => { mockPathname = '/app/app-1/access-config' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ permission_keys: [AppACLPermission.AccessConfig] }), ) @@ -466,7 +481,7 @@ describe('AppDetailLayout', () => { it('should redirect Agent app access config URLs to the Agent configure page', async () => { mockPathname = '/app/app-1/access-config' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ mode: AppModeEnum.AGENT, bound_agent_id: 'agent-1', @@ -507,13 +522,13 @@ describe('AppDetailLayout', () => { await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith('/agents/agent-1/configure') }) - expect(mockFetchAppDetailDirect).not.toHaveBeenCalled() + expect(mockAppResponse).not.toHaveBeenCalled() }) it('should redirect access config pages when RBAC is disabled', async () => { mockIsRbacEnabled = false mockPathname = '/app/app-1/access-config' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.AccessPointView], }), @@ -534,7 +549,7 @@ describe('AppDetailLayout', () => { it('should redirect annotation pages when log and annotation access is missing', async () => { mockPathname = '/app/app-1/annotations' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.Monitor], @@ -556,7 +571,7 @@ describe('AppDetailLayout', () => { it('should allow users with log and annotation access to open annotations directly', async () => { mockPathname = '/app/app-1/annotations' - mockFetchAppDetailDirect.mockResolvedValue( + mockAppResponse.mockResolvedValue( createAppDetail({ mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.LogAndAnnotation], diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 3fbb014a0fe..5ff979e6c09 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -1,6 +1,7 @@ 'use client' + +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' -import type { App } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' @@ -19,7 +20,7 @@ import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useDocumentTitle from '@/hooks/use-document-title' import { usePathname, useRouter } from '@/next/navigation' -import { fetchAppDetailDirect } from '@/service/apps' +import { consoleClient } from '@/service/console' import { AppModeEnum } from '@/types/app' import { getRedirectionPath } from '@/utils/app-redirection' import { getAppACLCapabilities } from '@/utils/permission' @@ -73,7 +74,7 @@ const AppDetailLayout: FC = (props) => { })), ) const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false) - const [appDetailRes, setAppDetailRes] = useState(null) + const [appDetailRes, setAppDetailRes] = useState(null) const routeAppDetail = appDetail?.id === appId ? appDetail : appDetailRes?.id === appId ? appDetailRes : null const pageTitle = appDetailPageTitle(pathname, t) @@ -113,8 +114,9 @@ const AppDetailLayout: FC = (props) => { void Promise.resolve().then(() => { if (!ignore) setIsLoadingAppDetail(true) }) - fetchAppDetailDirect({ url: '/apps', id: appId }) - .then((res: App) => { + consoleClient.apps.byAppId + .get({ params: { app_id: appId } }) + .then((res: AppDetailWithSite) => { if (ignore) return setAppDetailRes(res) diff --git a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx index c33168c2cc9..7ee51f8b97c 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx @@ -1,10 +1,12 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import { screen } from '@testing-library/react' import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import AppDetailSection from '../app-detail-section' -let mockAppMode = 'chat' +let mockAppMode: AppDetailWithSite['mode'] = 'chat' let mockPathname = '/app/app-1/logs' let mockAppPermissionKeys: string[] = [] let mockIsRbacEnabled = true @@ -26,7 +28,7 @@ const render = (ui: Parameters[0]) => vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: Record) => unknown) => selector({ - appDetail: { + appDetail: createAppDetailFixture({ id: 'app-1', name: 'Test App', mode: mockAppMode, @@ -34,7 +36,7 @@ vi.mock('@/app/components/app/store', () => ({ icon_type: 'emoji', icon_background: '#fff', permission_keys: mockAppPermissionKeys, - }, + }), }), })) @@ -200,7 +202,7 @@ describe('AppDetailSection', () => { ).not.toBeInTheDocument() }) - it.each(['workflow', 'advanced-chat'])( + it.each(['workflow', 'advanced-chat'] as const)( 'should render deploy navigation for a %s app with app deploy ACL regardless of the legacy workspace role', (mode) => { // Arrange @@ -221,12 +223,12 @@ describe('AppDetailSection', () => { it.each([ { label: 'the app is not a workflow app', - mode: 'chat', + mode: AppModeEnum.CHAT, permissionKeys: [AppACLPermission.Deploy], }, { label: 'app deploy ACL permission is missing', - mode: 'workflow', + mode: AppModeEnum.WORKFLOW, permissionKeys: [AppACLPermission.Monitor], }, ])('should hide deploy navigation when $label', ({ mode, permissionKeys }) => { diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-identity.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-identity.spec.tsx index 8023abf3136..76506b1e6d6 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-identity.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-info-identity.spec.tsx @@ -1,9 +1,10 @@ import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' import AppDetailSection from '../../app-detail-section' -let currentApp = { id: 'app-1', name: 'First app', mode: 'chat' } +let currentApp = createAppDetailFixture({ name: 'First app' }) const mockConsoleState = vi.hoisted(() => ({ current: { userProfile: { id: 'user-1' }, @@ -66,7 +67,7 @@ vi.mock('../app-info-modals', () => ({ describe('AppInfoView identity in the app detail sidebar', () => { beforeEach(() => { - currentApp = { id: 'app-1', name: 'First app', mode: 'chat' } + currentApp = createAppDetailFixture({ name: 'First app' }) }) it('keeps transient state for the same app and clears it when the app changes', async () => { @@ -80,12 +81,12 @@ describe('AppInfoView identity in the app detail sidebar', () => { expect(screen.getByText('First app: edit; secrets: 1')).toBeInTheDocument() screen.getByRole('button', { name: 'Open First app' }).focus() - currentApp = { id: 'app-1', name: 'Renamed app', mode: 'chat' } + currentApp = createAppDetailFixture({ name: 'Renamed app' }) view.rerender() expect(screen.getByText('Renamed app: edit; secrets: 1')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Open Renamed app' })).toHaveFocus() - currentApp = { id: 'app-2', name: 'Second app', mode: 'chat' } + currentApp = createAppDetailFixture({ id: 'app-2', name: 'Second app' }) view.rerender() expect(screen.getByRole('button', { name: 'Open Second app' })).toBeInTheDocument() expect(screen.getByText('Second app: closed; secrets: 0')).toBeInTheDocument() diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx index 06e0ca2314a..898248457f0 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx @@ -1,7 +1,8 @@ -import type { App } from '@/types/app' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' +import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import AppInfoModals from '../app-info-modals' @@ -107,8 +108,8 @@ vi.mock('@/app/components/app/export-confirm-modal', () => ({ ), })) -const createAppDetail = (overrides: Partial = {}): App => - ({ +const createAppDetail = (overrides: Partial = {}) => + createAppDetailFixture({ id: 'app-1', name: 'Test App', mode: AppModeEnum.CHAT, @@ -120,7 +121,7 @@ const createAppDetail = (overrides: Partial = {}): App => use_icon_as_answer_icon: false, max_active_requests: null, ...overrides, - }) as App + }) const defaultProps = { appDetail: createAppDetail(), diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx index baea07383d5..3c6c2a5c7ce 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx @@ -1,9 +1,10 @@ -import type { App } from '@/types/app' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { createAccountProfileQueryWrapper } from '@/test/console/account-profile' import { render as renderWithConsoleState } from '@/test/console/render' +import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import AppInfoTrigger from '../app-info-trigger' @@ -54,8 +55,8 @@ const defaultAppPermissionKeys = [ AppACLPermission.Delete, ] -const createAppDetail = (overrides: Partial = {}): App => - ({ +const createAppDetail = (overrides: Partial = {}) => + createAppDetailFixture({ id: 'app-1', name: 'Test App', mode: AppModeEnum.CHAT, @@ -68,7 +69,7 @@ const createAppDetail = (overrides: Partial = {}): App => permission_keys: defaultAppPermissionKeys, maintainer: 'user-1', ...overrides, - }) as App + }) const createProps = (overrides: Partial> = {}) => ({ appDetail: createAppDetail(), diff --git a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts index d38b42bf248..a430d076bc7 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts +++ b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts @@ -1,5 +1,9 @@ -import { act, renderHook } from '@testing-library/react' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' +import { act, waitFor } from '@testing-library/react' +import { useStore } from '@/app/components/app/store' import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderHookWithConsoleQuery } from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { useAppInfoActions } from '../use-app-info-actions' @@ -17,8 +21,6 @@ const toastMocks = vi.hoisted(() => { } }) const mockReplace = vi.fn() -const mockInvalidateQueries = vi.fn() -const mockSetAppDetail = vi.fn() const mockUpdateAppInfo = vi.fn() const mockCopyApp = vi.fn() const mockExportAppDsl = vi.fn() @@ -32,29 +34,33 @@ const mockMarkAppDeletionSucceeded = vi.fn() const mockMarkAppDeletionFailed = vi.fn() const mockGetSocket = vi.fn() const mockOnAppMetaUpdate = vi.fn() -const mockSetQueryData = vi.fn() -let mockAppDetail: Record | undefined = { - id: 'app-1', - name: 'Test App', - mode: AppModeEnum.CHAT, - icon: '๐Ÿค–', - icon_type: 'emoji', - icon_background: '#FFEAD5', +let mockAppDetail: AppDetailWithSite | undefined + +const appDetailQueryKey = consoleQuery.apps.byAppId.get.queryKey({ + input: { params: { app_id: 'app-1' } }, +}) +const appListQueryKeys = [ + consoleQuery.apps.get.key(), + consoleQuery.apps.starred.get.key(), + consoleQuery.apps.recent.get.key(), +] + +const renderActions = () => { + useStore.getState().setAppDetail(mockAppDetail) + const queryClient = createConsoleQueryClient() + if (mockAppDetail) queryClient.setQueryData(appDetailQueryKey, mockAppDetail) + for (const queryKey of appListQueryKeys) queryClient.setQueryData(queryKey, { data: [] }) + return renderHookWithConsoleQuery(() => useAppInfoActions(), { + queryClient, + systemFeatures: { rbac_enabled: true }, + }) } vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace }), })) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record) => unknown) => - selector({ - appDetail: mockAppDetail, - setAppDetail: mockSetAppDetail, - }), -})) - vi.mock('@/app/components/app/use-export-app-dsl', () => ({ useExportAppDsl: () => ({ exportAppDsl: mockExportAppDsl, @@ -78,27 +84,20 @@ vi.mock('@/app/notifications', () => ({ }), })) -vi.mock('@tanstack/react-query', () => ({ - queryOptions: (options: TOptions) => options, - useSuspenseQuery: () => ({ - data: { rbac_enabled: true }, - }), - useMutation: ({ mutationKey }: { mutationKey: unknown }) => ({ - mutateAsync: - JSON.stringify(mutationKey) === - JSON.stringify(consoleQuery.apps.byAppId.copy.post.mutationOptions().mutationKey) - ? mockCopyApp - : mockDeleteApp, - }), - useQueryClient: () => ({ - invalidateQueries: mockInvalidateQueries, - setQueryData: mockSetQueryData, - }), -})) - -vi.mock('@/service/apps', () => ({ - updateAppInfo: (...args: unknown[]) => mockUpdateAppInfo(...args), - fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args), +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + request: async (url: string, _init: RequestInit, { request }: { request: Request }) => { + if (request.method === 'GET') return Response.json(await mockFetchAppDetail(url)) + if (request.method === 'PUT') + return Response.json(await mockUpdateAppInfo(url, await request.json())) + if (request.method === 'POST' && new URL(url).pathname.endsWith('/copy')) + return Response.json(await mockCopyApp(url, await request.json())) + if (request.method === 'DELETE') { + await mockDeleteApp(url) + return new Response(null, { status: 204 }) + } + throw new Error(`Unexpected request: ${request.method} ${url}`) + }, })) vi.mock('@/service/app-deletion', () => ({ @@ -132,19 +131,19 @@ describe('useAppInfoActions', () => { mockExportWorkflowAppDsl.mockResolvedValue({ status: 'downloaded' }) mockOnAppMetaUpdate.mockReturnValue(() => {}) mockGetSocket.mockReturnValue(null) - mockAppDetail = { + mockAppDetail = createAppDetailFixture({ id: 'app-1', name: 'Test App', mode: AppModeEnum.CHAT, icon: '๐Ÿค–', icon_type: 'emoji', icon_background: '#FFEAD5', - } + }) }) describe('Initial state', () => { it('should return initial state correctly', () => { - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() expect(result.current.appDetail).toEqual(mockAppDetail) expect(result.current.activeModal).toBeNull() expect(result.current.secretEnvList).toEqual([]) @@ -153,7 +152,7 @@ describe('useAppInfoActions', () => { describe('Modal management', () => { it('should open modal', () => { - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() act(() => { result.current.openModal('edit') @@ -163,7 +162,7 @@ describe('useAppInfoActions', () => { }) it('should close modal', () => { - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() act(() => { result.current.openModal('delete') @@ -179,10 +178,14 @@ describe('useAppInfoActions', () => { describe('onEdit', () => { it('should update app info and close modal on success', async () => { - const updatedApp = { ...mockAppDetail, name: 'Updated', max_active_requests: null } + const updatedApp = createAppDetailFixture({ + ...mockAppDetail, + name: 'Updated', + max_active_requests: null, + }) mockUpdateAppInfo.mockResolvedValue(updatedApp) - const { result } = renderHook(() => useAppInfoActions()) + const { result, queryClient } = renderActions() await act(async () => { await result.current.onEdit({ @@ -195,37 +198,28 @@ describe('useAppInfoActions', () => { }) }) - expect(mockUpdateAppInfo).toHaveBeenCalled() - expect(mockSetQueryData).toHaveBeenCalledWith( - consoleQuery.apps.byAppId.get.queryKey({ - input: { params: { app_id: 'app-1' } }, - }), - expect.any(Function), - ) - const updateCachedApp = mockSetQueryData.mock.calls[0]![1] - expect(updateCachedApp({ id: 'app-1', name: 'Old name', max_active_requests: 5 })).toEqual( - expect.objectContaining({ id: 'app-1', name: 'Updated', max_active_requests: null }), - ) - expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: consoleQuery.apps.get.key(), + expect(mockUpdateAppInfo).toHaveBeenCalledWith(expect.stringContaining('/apps/app-1'), { + name: 'Updated', + icon_type: 'emoji', + icon: '๐Ÿค–', + icon_background: '#fff', + description: '', + use_icon_as_answer_icon: false, }) - expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: consoleQuery.apps.starred.get.key(), - }) - expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: consoleQuery.apps.recent.get.key(), - }) - expect(mockSetAppDetail).toHaveBeenCalledWith(updatedApp) + expect(queryClient.getQueryData(appDetailQueryKey)).toEqual(updatedApp) + for (const queryKey of appListQueryKeys) + expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(true) + expect(useStore.getState().appDetail).toEqual(updatedApp) expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.editDone' }) }) it('should emit app_meta_update after successful edit when collaboration socket exists', async () => { - const updatedApp = { ...mockAppDetail, name: 'Updated' } + const updatedApp = createAppDetailFixture({ ...mockAppDetail, name: 'Updated' }) const socket = { emit: vi.fn() } mockUpdateAppInfo.mockResolvedValue(updatedApp) mockGetSocket.mockReturnValue(socket) - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.onEdit({ @@ -237,7 +231,7 @@ describe('useAppInfoActions', () => { use_icon_as_answer_icon: false, }) }) - await new Promise((resolve) => setTimeout(resolve, 0)) + await waitFor(() => expect(socket.emit).toHaveBeenCalled()) expect(mockGetSocket).toHaveBeenCalledWith('app-1') expect(socket.emit).toHaveBeenCalledWith( @@ -251,7 +245,7 @@ describe('useAppInfoActions', () => { it('should notify error on edit failure', async () => { mockUpdateAppInfo.mockRejectedValue(new Error('fail')) - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.onEdit({ @@ -270,7 +264,7 @@ describe('useAppInfoActions', () => { it('should not call updateAppInfo when appDetail is undefined', async () => { mockAppDetail = undefined - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.onEdit({ @@ -291,7 +285,7 @@ describe('useAppInfoActions', () => { it.each(['completed', 'pending'] as const)( 'should redirect only when the copy is completed (%s)', async (status) => { - const newApp = { id: 'app-2', name: 'Copy', mode: 'chat' } + const newApp = createAppDetailFixture({ id: 'app-2', name: 'Copy' }) mockCopyApp.mockResolvedValue( status === 'completed' ? newApp @@ -302,7 +296,7 @@ describe('useAppInfoActions', () => { }, ) - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.onCopy({ @@ -313,9 +307,11 @@ describe('useAppInfoActions', () => { }) }) - expect(mockCopyApp).toHaveBeenCalledWith({ - params: { app_id: 'app-1' }, - body: { name: 'Copy', icon_type: 'emoji', icon: '๐Ÿค–', icon_background: '#fff' }, + expect(mockCopyApp).toHaveBeenCalledWith(expect.stringContaining('/apps/app-1/copy'), { + name: 'Copy', + icon_type: 'emoji', + icon: '๐Ÿค–', + icon_background: '#fff', }) if (status === 'completed') { expect(toastMocks.call).toHaveBeenCalledWith({ @@ -336,7 +332,7 @@ describe('useAppInfoActions', () => { it('should notify error on copy failure', async () => { mockCopyApp.mockRejectedValue(new Error('fail')) - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.onCopy({ @@ -358,7 +354,7 @@ describe('useAppInfoActions', () => { it('should not call copyApp when appDetail is undefined', async () => { mockAppDetail = undefined - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.onCopy({ @@ -375,7 +371,7 @@ describe('useAppInfoActions', () => { describe('onExport', () => { it('should export the app DSL', async () => { - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.onExport(false) @@ -391,7 +387,7 @@ describe('useAppInfoActions', () => { it('preserves the export failure result for the confirmation dialog', async () => { mockExportAppDsl.mockResolvedValue({ status: 'failed' }) - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await expect(result.current.onExport(true)).resolves.toBe(false) }) @@ -401,7 +397,7 @@ describe('useAppInfoActions', () => { it('should not export when appDetail is undefined', async () => { mockAppDetail = undefined - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.onExport() @@ -413,7 +409,7 @@ describe('useAppInfoActions', () => { describe('exportCheck', () => { it('should call onExport directly for non-workflow modes', async () => { - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.exportCheck() @@ -423,9 +419,9 @@ describe('useAppInfoActions', () => { }) it('should open export warning modal for workflow mode', async () => { - mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW } + mockAppDetail = createAppDetailFixture({ ...mockAppDetail, mode: AppModeEnum.WORKFLOW }) - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.exportCheck() @@ -435,9 +431,9 @@ describe('useAppInfoActions', () => { }) it('should open export warning modal for advanced_chat mode', async () => { - mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.ADVANCED_CHAT } + mockAppDetail = createAppDetailFixture({ ...mockAppDetail, mode: AppModeEnum.ADVANCED_CHAT }) - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.exportCheck() @@ -451,7 +447,7 @@ describe('useAppInfoActions', () => { it('should not do anything when appDetail is undefined', async () => { mockAppDetail = undefined - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.exportCheck() @@ -463,11 +459,11 @@ describe('useAppInfoActions', () => { describe('handleConfirmExport', () => { it('keeps the warning open after failure so the user can retry', async () => { - mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW } + mockAppDetail = createAppDetailFixture({ ...mockAppDetail, mode: AppModeEnum.WORKFLOW }) mockExportWorkflowAppDsl .mockResolvedValueOnce({ status: 'failed' }) .mockResolvedValueOnce({ status: 'downloaded' }) - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.exportCheck() }) @@ -482,8 +478,8 @@ describe('useAppInfoActions', () => { }) it('should export directly when no secret env variables', async () => { - mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW } - const { result } = renderHook(() => useAppInfoActions()) + mockAppDetail = createAppDetailFixture({ ...mockAppDetail, mode: AppModeEnum.WORKFLOW }) + const { result } = renderActions() await act(async () => { await result.current.handleConfirmExport() @@ -496,14 +492,14 @@ describe('useAppInfoActions', () => { }) it('should set secret env list when secret variables exist', async () => { - mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW } + mockAppDetail = createAppDetailFixture({ ...mockAppDetail, mode: AppModeEnum.WORKFLOW }) const secretVars = [{ value_type: 'secret', name: 'API_KEY', value: 'secret' }] mockExportWorkflowAppDsl.mockResolvedValue({ status: 'confirmation-required', secretEnvList: secretVars, }) - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.handleConfirmExport() @@ -517,7 +513,7 @@ describe('useAppInfoActions', () => { it('should not do anything when appDetail is undefined', async () => { mockAppDetail = undefined - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.handleConfirmExport() @@ -531,25 +527,25 @@ describe('useAppInfoActions', () => { it('should delete app and redirect on success', async () => { mockDeleteApp.mockResolvedValue({}) - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.onConfirmDelete() }) - expect(mockDeleteApp).toHaveBeenCalledWith({ params: { app_id: 'app-1' } }) + expect(mockDeleteApp).toHaveBeenCalledWith(expect.stringContaining('/apps/app-1')) expect(mockMarkAppDeletionStarted).toHaveBeenCalledWith('app-1') expect(mockMarkAppDeletionSucceeded).toHaveBeenCalledWith('app-1') expect(mockMarkAppDeletionFailed).not.toHaveBeenCalled() expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.appDeleted' }) expect(mockReplace).toHaveBeenCalledWith('/apps') - expect(mockSetAppDetail).toHaveBeenCalledWith() + expect(useStore.getState().appDetail).toBeUndefined() }) it('should not delete when appDetail is undefined', async () => { mockAppDetail = undefined - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.onConfirmDelete() @@ -561,7 +557,7 @@ describe('useAppInfoActions', () => { it('should notify error on delete failure', async () => { mockDeleteApp.mockRejectedValue({ message: 'cannot delete' }) - const { result } = renderHook(() => useAppInfoActions()) + const { result } = renderActions() await act(async () => { await result.current.onConfirmDelete() @@ -579,7 +575,7 @@ describe('useAppInfoActions', () => { describe('collaboration app meta updates', () => { it('should refresh app detail when receiving app_meta_update', async () => { - const updated = { ...mockAppDetail, name: 'Remote Updated' } + const updated = createAppDetailFixture({ ...mockAppDetail, name: 'Remote Updated' }) const unsubscribe = vi.fn() let onUpdate: (() => Promise) | undefined @@ -589,25 +585,16 @@ describe('useAppInfoActions', () => { }) mockFetchAppDetail.mockResolvedValue(updated) - const { unmount } = renderHook(() => useAppInfoActions()) - await new Promise((resolve) => setTimeout(resolve, 0)) + const { unmount, queryClient } = renderActions() + await waitFor(() => expect(onUpdate).toBeDefined()) await act(async () => { await onUpdate?.() }) - expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) - expect(mockSetQueryData).toHaveBeenCalledWith( - consoleQuery.apps.byAppId.get.queryKey({ - input: { params: { app_id: 'app-1' } }, - }), - expect.any(Function), - ) - const updateCachedApp = mockSetQueryData.mock.calls[0]![1] - expect(updateCachedApp({ id: 'app-1', name: 'Old name' })).toEqual( - expect.objectContaining({ id: 'app-1', name: 'Remote Updated' }), - ) - expect(mockSetAppDetail).toHaveBeenCalledWith(updated) + expect(mockFetchAppDetail).toHaveBeenCalledWith(expect.stringContaining('/apps/app-1')) + expect(queryClient.getQueryData(appDetailQueryKey)).toEqual(updated) + expect(useStore.getState().appDetail).toEqual(updated) unmount() expect(unsubscribe).toHaveBeenCalled() diff --git a/web/app/components/app-sidebar/app-info/app-info-modals.tsx b/web/app/components/app-sidebar/app-info/app-info-modals.tsx index df5b729d3ed..bde6f462c6b 100644 --- a/web/app/components/app-sidebar/app-info/app-info-modals.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-modals.tsx @@ -1,8 +1,10 @@ -import type { EnvironmentVariableItemResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { + AppDetailWithSite, + EnvironmentVariableItemResponse, +} from '@dify/contracts/api/console/apps/types.gen' import type { AppInfoModalType } from './use-app-info-actions' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' -import type { App } from '@/types/app' import { AlertDialog, AlertDialogActions, @@ -35,7 +37,7 @@ const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-ds }) type AppInfoModalsProps = { - appDetail: App + appDetail: AppDetailWithSite activeModal: AppInfoModalType closeModal: () => void secretEnvList: EnvironmentVariableItemResponse[] diff --git a/web/app/components/app-sidebar/app-info/app-info-trigger.tsx b/web/app/components/app-sidebar/app-info/app-info-trigger.tsx index 153776b6842..4d80f4fb5f3 100644 --- a/web/app/components/app-sidebar/app-info/app-info-trigger.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-trigger.tsx @@ -1,6 +1,6 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { Operation } from './app-operations' import type { AppInfoModalType } from './use-app-info-actions' -import type { App } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' @@ -15,7 +15,7 @@ import { getAppModeLabel } from './app-mode-labels' import AppOperations from './app-operations' type AppInfoTriggerProps = { - appDetail: App + appDetail: AppDetailWithSite expand: boolean openModal: (modal: Exclude) => void isExporting: boolean @@ -125,7 +125,7 @@ const AppInfoTrigger = ({ diff --git a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts index dec786d50c0..9cad4f422f7 100644 --- a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts +++ b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts @@ -4,7 +4,6 @@ import type { } from '@dify/contracts/api/console/apps/types.gen' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' -import type { App } from '@/types/app' import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -18,8 +17,7 @@ import { markAppDeletionStarted, markAppDeletionSucceeded, } from '@/service/app-deletion' -import { fetchAppDetail, updateAppInfo } from '@/service/apps' -import { consoleQuery } from '@/service/console' +import { consoleClient, consoleQuery } from '@/service/console' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' @@ -33,7 +31,7 @@ export type AppInfoModalType = | null type AppMetadata = Pick< - App, + AppDetailWithSite, | 'description' | 'icon' | 'icon_background' @@ -123,7 +121,7 @@ export function useAppInfoActions() { unsubscribe = collaborationManager.onAppMetaUpdate(async () => { try { - const res = await fetchAppDetail({ url: '/apps', id: appDetail.id }) + const res = await consoleClient.apps.byAppId.get({ params: { app_id: appDetail.id } }) if (disposed) return queryClient.setQueryData( consoleQuery.apps.byAppId.get.queryKey({ @@ -160,15 +158,17 @@ export function useAppInfoActions() { }) => { if (!appDetail) return try { - const app = await updateAppInfo({ - appID: appDetail.id, - name, - icon_type, - icon, - icon_background, - description, - use_icon_as_answer_icon, - max_active_requests, + const app = await consoleClient.apps.byAppId.put({ + params: { app_id: appDetail.id }, + body: { + name, + icon_type, + icon, + icon_background, + description, + use_icon_as_answer_icon, + max_active_requests, + }, }) closeModal() toast( diff --git a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx index 0df4ef82fe6..4fd5ec63630 100644 --- a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx @@ -1,11 +1,12 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { PublishedWorkflow } from '../shared/utils' -import type { App } from '@/types/app' import { QueryClientProvider } from '@tanstack/react-query' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { BlockEnum } from '@/app/components/workflow/types' import { toast } from '@/app/notifications' import { render } from '@/test/console/render' +import { createAppDetailFixture, createAppModelConfigFixture } from '@/test/fixtures/app' import { createTestQueryClient } from '@/test/query-client' import { AppModeEnum } from '@/types/app' import { MCPAccessPointCard } from '../built-in-access-points/mcp-card' @@ -61,11 +62,11 @@ vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({ }, })) -const appInfo = { +const appInfo = createAppDetailFixture({ api_base_url: 'https://api.example.test/v1', id: 'app-1', mode: AppModeEnum.CHAT, - model_config: { + model_config: createAppModelConfigFixture({ updated_at: 1_710_000_000, user_input_form: [ { @@ -76,14 +77,14 @@ const appInfo = { }, }, ], - }, -} as App + }), +}) -const workflowAppInfo = { +const workflowAppInfo = createAppDetailFixture({ ...appInfo, mode: AppModeEnum.WORKFLOW, model_config: null, -} as unknown as App +}) const publishedWorkflow = { graph: { @@ -109,7 +110,7 @@ function createDeferredPromise() { return { promise, reject, resolve } } -function renderCard(cardAppInfo: App = appInfo, workflow?: PublishedWorkflow) { +function renderCard(cardAppInfo: AppDetailWithSite = appInfo, workflow?: PublishedWorkflow) { const queryClient = createTestQueryClient() return render( diff --git a/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx b/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx index 8e48da87f0f..69a7acea2a3 100644 --- a/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx @@ -1,10 +1,11 @@ -import type { App } from '@/types/app' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import { QueryClientProvider } from '@tanstack/react-query' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useStore as useAppStore } from '@/app/components/app/store' import { toast } from '@/app/notifications' import { render } from '@/test/console/render' +import { createAppDetailFixture } from '@/test/fixtures/app' import { createTestQueryClient } from '@/test/query-client' import { AppModeEnum } from '@/types/app' import { ServiceApiAccessPointCard } from '../built-in-access-points/service-api-card' @@ -53,21 +54,24 @@ vi.mock('../shared/api-secret-key-button', () => ({ }, })) -function createAppInfo(mode: AppModeEnum, overrides: Partial = {}): App { - return { +function createAppInfo( + mode: AppModeEnum, + overrides: Partial = {}, +): AppDetailWithSite { + return createAppDetailFixture({ api_base_url: 'https://api.example.test/v1', enable_api: true, id: 'app-1', mode, ...overrides, - } as App + }) } function renderCard( mode: AppModeEnum, availability: 'available' | 'loading' | 'unavailable' = 'available', canManage = true, - overrides: Partial = {}, + overrides: Partial = {}, ) { useAppStore.setState({ appDetail: createAppInfo(mode, overrides) }) const queryClient = createTestQueryClient() diff --git a/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx b/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx index ab9b6e616dd..a0d9de65e82 100644 --- a/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx @@ -1,10 +1,10 @@ import type { AppTrigger } from '@/service/use-tools' -import type { App } from '@/types/app' import { QueryClientProvider } from '@tanstack/react-query' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { toast } from '@/app/notifications' import { render } from '@/test/console/render' +import { createAppDetailFixture } from '@/test/fixtures/app' import { createTestQueryClient } from '@/test/query-client' import { AppModeEnum } from '@/types/app' import { TriggerAccessPointCard } from '../built-in-access-points/trigger-card' @@ -69,10 +69,10 @@ vi.mock('@/app/components/workflow/block-icon', () => ({ default: () => null, })) -const appInfo = { +const appInfo = createAppDetailFixture({ id: 'app-1', mode: AppModeEnum.WORKFLOW, -} as App +}) function createTrigger(id: string, status: AppTrigger['status']): AppTrigger { return { diff --git a/web/app/components/app/access-point/__tests__/use-access-point-actions.spec.ts b/web/app/components/app/access-point/__tests__/use-access-point-actions.spec.ts index 711884b2afa..67091c8c8c0 100644 --- a/web/app/components/app/access-point/__tests__/use-access-point-actions.spec.ts +++ b/web/app/components/app/access-point/__tests__/use-access-point-actions.spec.ts @@ -1,14 +1,18 @@ -import type { ConfigParams } from '@/app/components/app/overview/settings' +import type { + AppSiteResponse, + AppSiteUpdatePayload, +} from '@dify/contracts/api/console/apps/types.gen' import { act, renderHook, waitFor } from '@testing-library/react' import { createQueryClientWrapper } from '@/test/console/query-client' +import { createAppDetailFixture } from '@/test/fixtures/app' import { createTestQueryClient } from '@/test/query-client' import { useAccessPointActions } from '../shared/use-access-point-actions' const mocks = vi.hoisted(() => ({ - fetchAppDetail: vi.fn().mockResolvedValue({ id: 'app-1' }), + fetchAppDetail: vi.fn(), setAppDetail: vi.fn(), toast: vi.fn(), - updateAppSiteConfig: vi.fn().mockResolvedValue({}), + updateAppSiteConfig: vi.fn(), })) vi.mock('@/app/notifications', () => ({ toast: mocks.toast })) @@ -18,12 +22,10 @@ vi.mock('@/app/components/app/store', () => ({ selector({ setAppDetail: mocks.setAppDetail }), })) -vi.mock('@/service/apps', () => ({ - fetchAppDetail: mocks.fetchAppDetail, - updateAppSiteConfig: mocks.updateAppSiteConfig, -})) - vi.mock('@/service/console', () => ({ + consoleClient: { + apps: { byAppId: { get: mocks.fetchAppDetail, site: { post: mocks.updateAppSiteConfig } } }, + }, consoleQuery: { apps: { get: { key: () => ['apps'] }, @@ -56,7 +58,7 @@ const siteConfig = { show_workflow_steps: false, title: 'App', use_icon_as_answer_icon: false, -} satisfies ConfigParams +} satisfies AppSiteUpdatePayload function renderActions(appId = 'app-1', canManageAccessPoint = true) { const queryClient = createTestQueryClient() @@ -70,6 +72,16 @@ function renderActions(appId = 'app-1', canManageAccessPoint = true) { describe('useAccessPointActions', () => { beforeEach(() => { vi.clearAllMocks() + mocks.fetchAppDetail.mockResolvedValue(createAppDetailFixture()) + mocks.updateAppSiteConfig.mockResolvedValue({ + app_id: 'app-1', + customize_token_strategy: 'not_allow', + default_language: 'en-US', + prompt_public: false, + show_workflow_steps: false, + title: 'Updated site', + use_icon_as_answer_icon: false, + } satisfies AppSiteResponse) }) it('refreshes after a successful access point result', async () => { @@ -78,8 +90,8 @@ describe('useAccessPointActions', () => { act(() => result.current.handleResult(null)) await waitFor(() => { - expect(mocks.fetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) - expect(mocks.setAppDetail).toHaveBeenCalledWith({ id: 'app-1' }) + expect(mocks.fetchAppDetail).toHaveBeenCalledWith({ params: { app_id: 'app-1' } }) + expect(mocks.setAppDetail).toHaveBeenCalledWith(createAppDetailFixture()) }) expect(mocks.toast).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully', { type: 'success', @@ -97,7 +109,7 @@ describe('useAccessPointActions', () => { }) }) - it('invalidates app detail and lists after saving legacy site configuration', async () => { + it('invalidates app detail and lists after saving site configuration', async () => { const { queryClient, result } = renderActions() const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') @@ -106,13 +118,14 @@ describe('useAccessPointActions', () => { }) expect(mocks.updateAppSiteConfig).toHaveBeenCalledWith({ - url: '/apps/app-1/site', + params: { app_id: 'app-1' }, body: siteConfig, }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['app-detail', 'app-1'] }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps'] }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'starred'] }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'recent'] }) + await waitFor(() => expect(mocks.setAppDetail).toHaveBeenCalledWith(createAppDetailFixture())) }) it('keeps site configuration behind Access Point management permission', async () => { diff --git a/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx b/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx index 54847d6f2c3..22068a2aa9d 100644 --- a/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx @@ -1,6 +1,6 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { PublishedWorkflow } from '../shared/utils' import type { InputVar, Node } from '@/app/components/workflow/types' -import type { App } from '@/types/app' import { QueryClientProvider } from '@tanstack/react-query' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -9,6 +9,7 @@ import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import { toast } from '@/app/notifications' import { AccessMode } from '@/models/access-control' import { render } from '@/test/console/render' +import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' import { createTestQueryClient } from '@/test/query-client' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' @@ -110,8 +111,8 @@ vi.mock('@/app/components/app/overview/embedded', () => ({ ) : null, })) -function createAppInfo(mode: AppModeEnum): App { - return { +function createAppInfo(mode: AppModeEnum): AppDetailWithSite { + return createAppDetailFixture({ access_mode: AccessMode.PUBLIC, api_base_url: 'https://api.example.test/v1', enable_site: true, @@ -121,11 +122,11 @@ function createAppInfo(mode: AppModeEnum): App { icon_url: null, id: 'app-1', mode, - site: { + site: createAppSiteFixture({ access_token: 'site-code', app_base_url: 'https://site.example.test', - }, - } as App + }), + }) } function renderCard( @@ -134,17 +135,21 @@ function renderCard( workflow?: PublishedWorkflow, { accessMode = AccessMode.PUBLIC, + appOverrides = {}, canManageAccessPoint = true, onRefreshApp = vi.fn().mockResolvedValue(undefined), showAccessControl = true, }: { - accessMode?: AccessMode + accessMode?: AccessMode | null + appOverrides?: Partial canManageAccessPoint?: boolean onRefreshApp?: () => Promise showAccessControl?: boolean } = {}, ) { - useAppStore.setState({ appDetail: { ...createAppInfo(mode), access_mode: accessMode } }) + useAppStore.setState({ + appDetail: { ...createAppInfo(mode), ...appOverrides, access_mode: accessMode }, + }) const queryClient = createTestQueryClient() queryClient.setQueryData(['system-features'], { webapp_auth: { enabled: showAccessControl }, @@ -259,6 +264,34 @@ describe('WebAppAccessPointCard', () => { vi.restoreAllMocks() }) + it('disables site actions when the app has no site', () => { + renderCard(AppModeEnum.CHAT, 'available', undefined, { appOverrides: { site: null } }) + + expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeDisabled() + expect( + screen.getByRole('button', { name: /studio\.accessPoint\.embedIntoSite/ }), + ).toBeDisabled() + expect( + screen.queryByRole('link', { name: /studio\.accessPoint\.open/ }), + ).not.toBeInTheDocument() + expect(screen.queryByText('https://site.example.test/chat/')).not.toBeInTheDocument() + }) + + it('does not present an unknown access mode as public or allow launching', () => { + renderCard(AppModeEnum.CHAT, 'available', undefined, { accessMode: null }) + + expect( + screen.queryByRole('button', { name: /accessControlDialog\.accessItems\.anyone/ }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: /studio\.accessPoint\.open/ }), + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /studio\.accessPoint\.open/ })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + }) + it('shows the current access mode without a redundant section label', () => { renderCard(AppModeEnum.CHAT) diff --git a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx index c95629d781b..f746bf4f6e5 100644 --- a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx @@ -1,7 +1,7 @@ 'use client' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { PublishedWorkflow } from '../shared/utils' -import type { App } from '@/types/app' import { AlertDialog, AlertDialogActions, @@ -31,7 +31,7 @@ import { useAccessPointStatusLabel } from '../shared/use-access-point-status-lab import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils' type MCPAccessPointCardProps = { - appInfo: App + appInfo: AppDetailWithSite canManageAccessPoint: boolean highlighted?: boolean triggerModeDisabled: boolean diff --git a/web/app/components/app/access-point/built-in-access-points/service-api-card.tsx b/web/app/components/app/access-point/built-in-access-points/service-api-card.tsx index 06096c7773b..c6e5f06b658 100644 --- a/web/app/components/app/access-point/built-in-access-points/service-api-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/service-api-card.tsx @@ -1,7 +1,7 @@ 'use client' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { AccessPointAvailability } from '@/app/components/base/access-point/status' -import type { App } from '@/types/app' import { useMutation } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' @@ -12,7 +12,7 @@ import { ServiceApiCardView } from '../shared/service-api-card-view' import { getBuiltInAccessUrls } from '../shared/utils' type ServiceApiAccessPointCardProps = { - appInfo: App + appInfo: AppDetailWithSite availability: AccessPointAvailability canManage: boolean highlighted?: boolean diff --git a/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx b/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx index 3998bfeadc5..abf8c9bf080 100644 --- a/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx @@ -1,8 +1,8 @@ 'use client' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' import type { AppTrigger } from '@/service/use-tools' -import type { App } from '@/types/app' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Switch } from '@langgenius/dify-ui/switch' import { useMutation } from '@tanstack/react-query' @@ -131,7 +131,7 @@ function TriggerAccessPointItem({ } type TriggerAccessPointCardProps = { - appInfo: App + appInfo: AppDetailWithSite availability: 'available' | 'loading' | 'unavailable' canManageAccessPoint: boolean highlighted?: boolean diff --git a/web/app/components/app/access-point/built-in-access-points/web-app-card.tsx b/web/app/components/app/access-point/built-in-access-points/web-app-card.tsx index b278c4ecb96..516294a81bc 100644 --- a/web/app/components/app/access-point/built-in-access-points/web-app-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/web-app-card.tsx @@ -1,10 +1,12 @@ 'use client' +import type { + AppDetailWithSite, + AppSiteUpdatePayload, +} from '@dify/contracts/api/console/apps/types.gen' import type { SelectorParam } from 'i18next' import type { PublishedWorkflow } from '../shared/utils' -import type { ConfigParams } from '@/app/components/app/overview/settings' import type { AccessPointAvailability } from '@/app/components/base/access-point/status' -import type { App } from '@/types/app' import { AlertDialog, AlertDialogActions, @@ -58,14 +60,14 @@ const ACCESS_MODE_LABEL_MAP: Record> = { } type WebAppAccessPointCardProps = { - appInfo: App + appInfo: AppDetailWithSite availability: AccessPointAvailability canDeploy: boolean canManageAccessPoint: boolean highlighted?: boolean showAccessControl: boolean onRefreshApp: () => Promise - onSaveSiteConfig: (params: ConfigParams) => Promise + onSaveSiteConfig: (params: AppSiteUpdatePayload) => Promise workflow: PublishedWorkflow } @@ -127,19 +129,19 @@ export function WebAppAccessPointCard({ }, }), ) + const site = appInfo.site + const siteAvailability = site ? availability : 'unavailable' const { webApp: webAppUrl } = getBuiltInAccessUrls(appInfo) const pendingEnabled = toggleSiteMutation.variables?.body.enable_site const optimisticEnabled = toggleSiteMutation.isPending && pendingEnabled !== undefined ? pendingEnabled : appInfo.enable_site - const running = availability === 'available' && optimisticEnabled - const actionsAvailable = running && !toggleSiteMutation.isPending + const running = siteAvailability === 'available' && optimisticEnabled + const actionsAvailable = running && Boolean(webAppUrl) && !toggleSiteMutation.isPending const supportsEmbedded = appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW const hiddenLaunchVariables = getHiddenStartInputs(workflow) - const accessIcon = ACCESS_MODE_ICON_MAP[appInfo.access_mode] - const accessLabel = ACCESS_MODE_LABEL_MAP[appInfo.access_mode] const { data: accessSubjects } = useAppWhiteListSubjects( appInfo.id, showAccessControl && @@ -156,8 +158,8 @@ export function WebAppAccessPointCard({ }) const noAccessPermission = showAccessControl && - appInfo.access_mode !== AccessMode.EXTERNAL_MEMBERS && - !userCanAccessApp?.result + (appInfo.access_mode === null || + (appInfo.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result)) const handleRegenerate = () => { if (!canManageAccessPoint || resetSiteAccessToken.isPending) return @@ -178,7 +180,7 @@ export function WebAppAccessPointCard({ }) } - const status = getAccessPointStatus(availability, running) + const status = getAccessPointStatus(siteAvailability, running) const statusLabel = useAccessPointStatusLabel(status) return ( @@ -192,7 +194,7 @@ export function WebAppAccessPointCard({ @@ -202,7 +204,7 @@ export function WebAppAccessPointCard({ highlighted={highlighted} switchDisabled={!canManageAccessPoint} switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })} - onEnabledChange={availability === 'available' ? handleEnabledChange : undefined} + onEnabledChange={siteAvailability === 'available' ? handleEnabledChange : undefined} actions={ <> {hiddenLaunchVariables.length > 0 && ( @@ -241,7 +243,7 @@ export function WebAppAccessPointCard({ { - setAppIcon(payload) - }} + onConfirm={setSelectedIcon} /> ) diff --git a/web/app/components/app/log-annotation/__tests__/index.spec.tsx b/web/app/components/app/log-annotation/__tests__/index.spec.tsx index d5f46445e6d..09200aab8f4 100644 --- a/web/app/components/app/log-annotation/__tests__/index.spec.tsx +++ b/web/app/components/app/log-annotation/__tests__/index.spec.tsx @@ -1,57 +1,37 @@ -import type { App, AppIconType } from '@/types/app' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import { render, screen } from '@testing-library/react' import { useStore as useAppStore } from '@/app/components/app/store' import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type' +import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import LogAnnotation from '../index' vi.mock('@/app/components/app/annotation', () => ({ - default: ({ appDetail }: { appDetail: App }) => ( + default: ({ appDetail }: { appDetail: AppDetailWithSite }) => (
{appDetail.id}
), })) vi.mock('@/app/components/app/log', () => ({ - default: ({ appDetail }: { appDetail: App }) => ( + default: ({ appDetail }: { appDetail: AppDetailWithSite }) => (
{appDetail.id}
), })) vi.mock('@/app/components/app/workflow-log', () => ({ - default: ({ appDetail }: { appDetail: App }) => ( + default: ({ appDetail }: { appDetail: AppDetailWithSite }) => (
{appDetail.id}
), })) -const createMockApp = (overrides: Partial = {}): App => ({ - id: 'app-123', - name: 'Test App', - description: 'Test app description', - author_name: 'Test Author', - icon_type: 'emoji' as AppIconType, - icon: ':icon:', - icon_background: '#FFEAD5', - icon_url: null, - use_icon_as_answer_icon: false, - mode: AppModeEnum.CHAT, - enable_site: true, - enable_api: true, - api_rpm: 60, - api_rph: 3600, - is_demo: false, - model_config: {} as App['model_config'], - app_model_config: {} as App['app_model_config'], - created_at: Date.now(), - updated_at: Date.now(), - site: { - access_token: 'token', - app_base_url: 'https://example.com', - } as App['site'], - api_base_url: 'https://api.example.com', - tags: [], - access_mode: 'public_access' as App['access_mode'], - ...overrides, -}) +const createMockApp = (overrides: Partial = {}): AppDetailWithSite => + createAppDetailFixture({ + id: 'app-123', + name: 'Test App', + mode: 'chat', + site: createAppSiteFixture({ access_token: 'token', app_base_url: 'https://example.com' }), + ...overrides, + }) describe('LogAnnotation', () => { beforeEach(() => { diff --git a/web/app/components/app/log/__tests__/empty-element.spec.tsx b/web/app/components/app/log/__tests__/empty-element.spec.tsx index 26b34d57119..6f48de616c5 100644 --- a/web/app/components/app/log/__tests__/empty-element.spec.tsx +++ b/web/app/components/app/log/__tests__/empty-element.spec.tsx @@ -1,6 +1,7 @@ -import type { App } from '@/types/app' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import { screen } from '@testing-library/react' import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import EmptyElement from '../empty-element' @@ -29,7 +30,8 @@ vi.mock('react-i18next', async () => { }) vi.mock('@/utils/app-redirection', () => ({ - getRedirectionPath: (isTest: boolean, _app: App) => (isTest ? '/test-path' : '/prod-path'), + getRedirectionPath: (isTest: boolean, _app: AppDetailWithSite) => + isTest ? '/test-path' : '/prod-path', })) vi.mock('@/utils/var', () => ({ @@ -38,7 +40,7 @@ vi.mock('@/utils/var', () => ({ describe('EmptyElement', () => { const createMockAppDetail = (mode: AppModeEnum) => - ({ + createAppDetailFixture({ id: 'test-app-id', name: 'Test App', description: 'Test description', @@ -49,11 +51,11 @@ describe('EmptyElement', () => { enable_site: true, enable_api: true, created_at: Date.now(), - site: { + site: createAppSiteFixture({ access_token: 'test-token', app_base_url: 'https://app.example.com', - }, - }) as unknown as App + }), + }) describe('Rendering', () => { it('should render empty element with title', () => { @@ -113,6 +115,17 @@ describe('EmptyElement', () => { }) }) + it.each([null, createAppSiteFixture({ access_token: null })])( + 'keeps the editor link without offering an unavailable site link', + (site) => { + render() + + const links = screen.getAllByRole('link') + expect(links).toHaveLength(1) + expect(links[0]).toHaveAttribute('href', '/test-path') + }, + ) + describe('Links', () => { it('should render share link with correct attributes', () => { const appDetail = createMockAppDetail(AppModeEnum.CHAT) diff --git a/web/app/components/app/log/empty-element.tsx b/web/app/components/app/log/empty-element.tsx index c7614049333..3a692b63430 100644 --- a/web/app/components/app/log/empty-element.tsx +++ b/web/app/components/app/log/empty-element.tsx @@ -1,6 +1,7 @@ 'use client' + +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { FC, SVGProps } from 'react' -import type { App } from '@/types/app' import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' @@ -34,7 +35,7 @@ const ThreeDotsIcon = ({ className }: SVGProps) => { ) } -const EmptyElement: FC<{ appDetail: App }> = ({ appDetail }) => { +const EmptyElement: FC<{ appDetail: AppDetailWithSite }> = ({ appDetail }) => { const { t } = useTranslation(['appLog']) const { data: currentUserId } = useSuspenseQuery({ ...userProfileQueryOptions(), @@ -44,7 +45,7 @@ const EmptyElement: FC<{ appDetail: App }> = ({ appDetail }) => { const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isRbacEnabled = systemFeatures.rbac_enabled - const getWebAppType = (appType: AppModeEnum) => { + const getWebAppType = (appType: AppDetailWithSite['mode']) => { if (appType !== AppModeEnum.COMPLETION && appType !== AppModeEnum.WORKFLOW) return AppModeEnum.CHAT return appType @@ -62,13 +63,15 @@ const EmptyElement: FC<{ appDetail: App }> = ({ appDetail }) => { i18nKey={($) => $['table.empty.element.content']} ns="appLog" components={{ - shareLink: ( + shareLink: appDetail.site?.access_token ? ( + ) : ( + ), testLink: ( void } @@ -158,7 +159,7 @@ const statusTdRender = (statusCount: StatusCount) => { } type IDetailPanel = { - appDetail: App + appDetail: AppDetailWithSite detail: any onClose: () => void onFeedback: FeedbackFunc @@ -645,7 +646,7 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { } type ConversationDetailProps = { - appDetail: App + appDetail: AppDetailWithSite conversationId?: string onClose: () => void } diff --git a/web/app/components/app/overview/customize/index.tsx b/web/app/components/app/overview/customize/index.tsx index dd1bcba232a..0a8d90b0525 100644 --- a/web/app/components/app/overview/customize/index.tsx +++ b/web/app/components/app/overview/customize/index.tsx @@ -1,4 +1,5 @@ 'use client' +import type { AppMode } from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' import { buttonVariants } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' @@ -20,7 +21,7 @@ type IShareLinkProps = { onClose: () => void api_base_url: string appId: string - mode?: AppModeEnum + mode?: AppMode sourceCodeRepository?: 'webapp-conversation' | 'webapp-text-generator' } diff --git a/web/app/components/app/overview/embedded/index.tsx b/web/app/components/app/overview/embedded/index.tsx index af9f90720e9..0ec890fa666 100644 --- a/web/app/components/app/overview/embedded/index.tsx +++ b/web/app/components/app/overview/embedded/index.tsx @@ -1,10 +1,10 @@ +import type { AppDetailSiteResponse } from '@dify/contracts/api/console/apps/types.gen' import type { MutableRefObject } from 'react' import type { EmbeddedWebAppRoute, WorkflowHiddenStartVariable, WorkflowLaunchInputValue, } from '../app-card-utils' -import type { SiteInfo } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { IconButton } from '@langgenius/dify-ui/icon-button' @@ -29,7 +29,7 @@ import WorkflowHiddenInputFields from '../workflow-hidden-input-fields' import style from './style.module.css' type Props = Readonly<{ - siteInfo?: SiteInfo + siteInfo?: Partial> isShow: boolean onClose: () => void accessToken?: string diff --git a/web/app/components/app/overview/settings/__tests__/index.spec.tsx b/web/app/components/app/overview/settings/__tests__/index.spec.tsx index 8a43022fcfa..f3dc21ab1dc 100644 --- a/web/app/components/app/overview/settings/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/settings/__tests__/index.spec.tsx @@ -7,6 +7,7 @@ import { createConsoleQueryClient, renderWithConsoleQuery as renderWithoutPricing, } from '@/test/console/query-data' +import { createAppSiteFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import SettingsModal from '../index' @@ -132,6 +133,29 @@ describe('SettingsModal', () => { vi.useRealTimers() }) + it('edits a site with nullable optional fields as empty form values', async () => { + renderSettingsModal({ id: 'app-null-fields', mode: 'chat', site: createAppSiteFixture() }) + + expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue('') + fireEvent.click(screen.getByText('common.operation.save')) + + await waitFor(() => + expect(mockOnSave).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'App', + description: '', + chat_color_theme: '', + privacy_policy: '', + input_placeholder: '', + icon: null, + icon_type: null, + icon_background: null, + }), + ), + ) + expect(mockOnClose).toHaveBeenCalledOnce() + }) + it('should render the modal with all settings exposed by default', async () => { renderSettingsModal() expect(screen.getByText('appOverview.overview.appInfo.settings.title')).toBeInTheDocument() diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index f313bc35fa0..37fc01f41ab 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -1,8 +1,13 @@ 'use client' +import type { + AppDetailSiteResponse, + AppDetailWithSite, + AppSiteUpdatePayload, +} from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' import type { IconPickerValue } from '@/app/components/base/icon-picker' -import type { AppIconType, Language, SiteConfig } from '@/types/app' +import type { Language } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' @@ -56,11 +61,11 @@ type ISettingsModalProps = { isShow: boolean defaultValue?: string onClose: () => void - onSave?: (params: ConfigParams) => Promise + onSave?: (params: AppSiteUpdatePayload) => Promise } type SettingsSiteInfo = Pick< - SiteConfig, + AppDetailSiteResponse, | 'title' | 'description' | 'default_language' @@ -80,6 +85,7 @@ type SettingsSiteInfo = Pick< type SettingsAppIconSelection = | IconPickerValue + | null | { type: 'link' icon: string @@ -88,30 +94,12 @@ type SettingsAppIconSelection = export type SettingsAppInfo = { id: string - mode: AppModeEnum + mode: AppDetailWithSite['mode'] site: SettingsSiteInfo } -export type ConfigParams = { - title: string - description: string - default_language: string - chat_color_theme: string - chat_color_theme_inverted: boolean - prompt_public: boolean - copyright?: string - privacy_policy: string - custom_disclaimer: string - input_placeholder?: string - icon_type: AppIconType - icon: string - icon_background?: string - show_workflow_steps: boolean - use_icon_as_answer_icon: boolean -} - const INPUT_PLACEHOLDER_MAX_LENGTH = 64 -const INPUT_PLACEHOLDER_SUPPORTED_MODES: ReadonlyArray = [ +const INPUT_PLACEHOLDER_SUPPORTED_MODES: ReadonlyArray = [ AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.ADVANCED_CHAT, @@ -141,12 +129,12 @@ const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => { return { title, - desc: description, - chatColorTheme: chat_color_theme, + desc: description ?? '', + chatColorTheme: chat_color_theme ?? '', chatColorThemeInverted: chat_color_theme_inverted, - copyright, + copyright: copyright ?? '', copyrightSwitchValue: !!copyright, - privacyPolicy: privacy_policy, + privacyPolicy: privacy_policy ?? '', customDisclaimer: custom_disclaimer, inputPlaceholder: input_placeholder ?? '', show_workflow_steps, @@ -157,11 +145,14 @@ const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => { const createAppIcon = (appInfo: ISettingsModalProps['appInfo']): SettingsAppIconSelection => { const { icon_type, icon, icon_background, icon_url } = appInfo.site - if (icon_type === 'image') return { type: 'image', url: icon_url!, fileId: icon } + if (icon_type === 'image' && icon) return { type: 'image', url: icon_url ?? '', fileId: icon } - if (icon_type === 'link') return { type: 'link', icon, url: icon } + if (icon_type === 'link' && icon) return { type: 'link', icon, url: icon } - return { type: 'emoji', icon, background: icon_background! } + if (icon_type === 'emoji' && icon && icon_background) + return { type: 'emoji', icon, background: icon_background } + + return null } const getSettingsResetKey = (appInfo: ISettingsModalProps['appInfo']) => @@ -339,9 +330,17 @@ const SettingsModal: FC = ({ : !INPUT_PLACEHOLDER_SUPPORTED_MODES.includes(appInfo.mode) ? '' : (inputInfo.inputPlaceholder ?? '').slice(0, INPUT_PLACEHOLDER_MAX_LENGTH), - icon_type: appIcon.type, - icon: appIcon.type === 'image' ? appIcon.fileId : appIcon.icon, - icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, + icon_type: appIcon?.type ?? appInfo.site.icon_type, + icon: appIcon + ? appIcon.type === 'image' + ? appIcon.fileId + : appIcon.icon + : appInfo.site.icon, + icon_background: appIcon + ? appIcon.type === 'emoji' + ? appIcon.background + : undefined + : appInfo.site.icon_background, show_workflow_steps: inputInfo.show_workflow_steps, use_icon_as_answer_icon: inputInfo.use_icon_as_answer_icon, } @@ -447,10 +446,28 @@ const SettingsModal: FC = ({ setShowIconPicker(true) }} className="mt-2 cursor-pointer" - iconType={appIcon.type === 'link' ? 'image' : appIcon.type} - icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} - background={appIcon.type === 'emoji' ? appIcon.background : undefined} - imageUrl={appIcon.type === 'emoji' ? undefined : appIcon.url} + iconType={ + appIcon + ? appIcon.type === 'link' + ? 'image' + : appIcon.type + : appInfo.site.icon_type + } + icon={ + appIcon + ? appIcon.type === 'image' + ? appIcon.fileId + : appIcon.icon + : (appInfo.site.icon ?? undefined) + } + background={ + appIcon?.type === 'emoji' + ? appIcon.background + : appInfo.site.icon_background + } + imageUrl={ + appIcon && appIcon.type !== 'emoji' ? appIcon.url : appInfo.site.icon_url + } /> {/* description */} @@ -805,7 +822,7 @@ const SettingsModal: FC = ({ diff --git a/web/app/components/app/store.ts b/web/app/components/app/store.ts index bf1d8549aee..64e8dbbdab0 100644 --- a/web/app/components/app/store.ts +++ b/web/app/components/app/store.ts @@ -1,9 +1,9 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { IChatItem } from '@/app/components/base/chat/chat/type' -import type { App } from '@/types/app' import { create } from 'zustand' type State = { - appDetail?: App + appDetail?: AppDetailWithSite currentLogItem?: IChatItem currentLogModalActiveTab: string showPromptLogModal: boolean @@ -13,7 +13,7 @@ type State = { } type Action = { - setAppDetail: (appDetail?: App) => void + setAppDetail: (appDetail?: AppDetailWithSite) => void setCurrentLogItem: (item?: IChatItem) => void setCurrentLogModalActiveTab: (tab: string) => void setShowPromptLogModal: (showPromptLogModal: boolean) => void diff --git a/web/app/components/app/workflow-log/__tests__/detail.spec.tsx b/web/app/components/app/workflow-log/__tests__/detail.spec.tsx index 3d57970956b..e3b024689e8 100644 --- a/web/app/components/app/workflow-log/__tests__/detail.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/detail.spec.tsx @@ -1,3 +1,4 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' /** * DetailPanel Component Tests * @@ -7,11 +8,10 @@ * - Close button * - Run component with detail/tracing URLs */ - -import type { App, AppIconType, AppModeEnum } from '@/types/app' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useStore as useAppStore } from '@/app/components/app/store' +import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' import DetailPanel from '../detail' // ============================================================================ @@ -58,35 +58,14 @@ vi.mock('ahooks', () => ({ // Test Data Factories // ============================================================================ -const createMockApp = (overrides: Partial = {}): App => ({ - id: 'test-app-id', - name: 'Test App', - description: 'Test app description', - author_name: 'Test Author', - icon_type: 'emoji' as AppIconType, - icon: '๐Ÿš€', - icon_background: '#FFEAD5', - icon_url: null, - use_icon_as_answer_icon: false, - mode: 'workflow' as AppModeEnum, - enable_site: true, - enable_api: true, - api_rpm: 60, - api_rph: 3600, - is_demo: false, - model_config: {} as App['model_config'], - app_model_config: {} as App['app_model_config'], - created_at: Date.now(), - updated_at: Date.now(), - site: { - access_token: 'token', - app_base_url: 'https://example.com', - } as App['site'], - api_base_url: 'https://api.example.com', - tags: [], - access_mode: 'public_access' as App['access_mode'], - ...overrides, -}) +const createMockApp = (overrides: Partial = {}): AppDetailWithSite => + createAppDetailFixture({ + id: 'test-app-id', + name: 'Test App', + mode: 'workflow', + site: createAppSiteFixture({ access_token: 'token', app_base_url: 'https://example.com' }), + ...overrides, + }) // ============================================================================ // Tests diff --git a/web/app/components/app/workflow-log/__tests__/index.spec.tsx b/web/app/components/app/workflow-log/__tests__/index.spec.tsx index 0fdc18e09c4..fdebf47c65b 100644 --- a/web/app/components/app/workflow-log/__tests__/index.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/index.spec.tsx @@ -1,3 +1,4 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { UseQueryResult } from '@tanstack/react-query' /** * Logs Container Component Tests @@ -18,7 +19,7 @@ import type { MockedFunction } from 'vite-plus/test' import type { CloudSandboxPlanState } from '../../log/cloud-sandbox-retention' import type { ILogsProps } from '../index' import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log' -import type { App, AppIconType, AppModeEnum } from '@/types/app' +import type { AppModeEnum } from '@/types/app' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import dayjs from 'dayjs' @@ -27,6 +28,7 @@ import { WorkflowRunTriggeredFrom } from '@/models/log' import * as useLogModule from '@/service/use-log' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { render } from '@/test/console/render' +import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' import { createNuqsTestWrapper } from '@/test/nuqs-testing' import { TIME_PERIOD_MAPPING } from '../filter' import Logs from '../index' @@ -169,35 +171,14 @@ const createMockQueryResult = ( // Test Data Factories // ============================================================================ -const createMockApp = (overrides: Partial = {}): App => ({ - id: 'test-app-id', - name: 'Test App', - description: 'Test app description', - author_name: 'Test Author', - icon_type: 'emoji' as AppIconType, - icon: '๐Ÿš€', - icon_background: '#FFEAD5', - icon_url: null, - use_icon_as_answer_icon: false, - mode: 'workflow' as AppModeEnum, - enable_site: true, - enable_api: true, - api_rpm: 60, - api_rph: 3600, - is_demo: false, - model_config: {} as App['model_config'], - app_model_config: {} as App['app_model_config'], - created_at: Date.now(), - updated_at: Date.now(), - site: { - access_token: 'token', - app_base_url: 'https://example.com', - } as App['site'], - api_base_url: 'https://api.example.com', - tags: [], - access_mode: 'public_access' as App['access_mode'], - ...overrides, -}) +const createMockApp = (overrides: Partial = {}): AppDetailWithSite => + createAppDetailFixture({ + id: 'test-app-id', + name: 'Test App', + mode: 'workflow', + site: createAppSiteFixture({ access_token: 'token', app_base_url: 'https://example.com' }), + ...overrides, + }) const createMockWorkflowRun = (overrides: Partial = {}): WorkflowRunDetail => ({ id: 'run-1', diff --git a/web/app/components/app/workflow-log/__tests__/list.spec.tsx b/web/app/components/app/workflow-log/__tests__/list.spec.tsx index fbeaa2af2c5..224b088f90a 100644 --- a/web/app/components/app/workflow-log/__tests__/list.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/list.spec.tsx @@ -1,3 +1,5 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' +import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log' /** * WorkflowAppLogList Component Tests * @@ -8,14 +10,13 @@ * - Drawer with run details * - Loading states */ - -import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log' -import type { App, AppIconType, AppModeEnum } from '@/types/app' +import type { AppModeEnum } from '@/types/app' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useStore as useAppStore } from '@/app/components/app/store' import { APP_PAGE_LIMIT } from '@/config' import { WorkflowRunTriggeredFrom } from '@/models/log' +import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' import WorkflowAppLogList from '../list' // ============================================================================ @@ -90,35 +91,14 @@ vi.mock('ahooks', () => ({ // Test Data Factories // ============================================================================ -const createMockApp = (overrides: Partial = {}): App => ({ - id: 'test-app-id', - name: 'Test App', - description: 'Test app description', - author_name: 'Test Author', - icon_type: 'emoji' as AppIconType, - icon: '๐Ÿš€', - icon_background: '#FFEAD5', - icon_url: null, - use_icon_as_answer_icon: false, - mode: 'workflow' as AppModeEnum, - enable_site: true, - enable_api: true, - api_rpm: 60, - api_rph: 3600, - is_demo: false, - model_config: {} as App['model_config'], - app_model_config: {} as App['app_model_config'], - created_at: Date.now(), - updated_at: Date.now(), - site: { - access_token: 'token', - app_base_url: 'https://example.com', - } as App['site'], - api_base_url: 'https://api.example.com', - tags: [], - access_mode: 'public_access' as App['access_mode'], - ...overrides, -}) +const createMockApp = (overrides: Partial = {}): AppDetailWithSite => + createAppDetailFixture({ + id: 'test-app-id', + name: 'Test App', + mode: 'workflow', + site: createAppSiteFixture({ access_token: 'token', app_base_url: 'https://example.com' }), + ...overrides, + }) const createMockWorkflowRun = (overrides: Partial = {}): WorkflowRunDetail => ({ id: 'run-1', diff --git a/web/app/components/app/workflow-log/index.tsx b/web/app/components/app/workflow-log/index.tsx index 7fb955cf4f8..8b28ddf8073 100644 --- a/web/app/components/app/workflow-log/index.tsx +++ b/web/app/components/app/workflow-log/index.tsx @@ -1,6 +1,7 @@ 'use client' + +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' -import type { App } from '@/types/app' import { Pagination } from '@langgenius/dify-ui/pagination' import { useQuery } from '@tanstack/react-query' import { useDebounce } from 'ahooks' @@ -32,7 +33,7 @@ dayjs.extend(utc) dayjs.extend(timezone) export type ILogsProps = { - appDetail: App + appDetail: AppDetailWithSite } export type QueryParam = { diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index 89eeb633fd7..c3558dbb266 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -1,11 +1,12 @@ 'use client' + +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunTriggeredFrom, } from '@/models/log' -import type { App } from '@/types/app' import { ArrowDownIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' import { @@ -30,7 +31,7 @@ import TriggerByDisplay from './trigger-by-display' type ILogs = { logs?: WorkflowLogsResponse - appDetail?: App + appDetail?: AppDetailWithSite onRefresh: () => void } diff --git a/web/app/components/base/chat/types.ts b/web/app/components/base/chat/types.ts index be36cd96a63..e207da05c2f 100644 --- a/web/app/components/base/chat/types.ts +++ b/web/app/components/base/chat/types.ts @@ -1,6 +1,7 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { WorkflowRunningStatus } from '@/app/components/workflow/types' +import type { AgentConfig } from '@/models/debug' import type { ModelConfig } from '@/types/app' import type { HumanInputFilledFormData, HumanInputFormData, NodeTracing } from '@/types/workflow' @@ -12,13 +13,14 @@ export type EnableType = { enabled: boolean } -export type ChatConfig = Omit & { +export type ChatConfig = Omit & { + agent_mode: Pick & { strategy?: AgentConfig['strategy'] } supportAnnotation?: boolean appId?: string questionEditEnable?: boolean supportFeedback?: boolean supportCitationHitInfo?: boolean - system_parameters: { + system_parameters?: { audio_file_size_limit: number file_size_limit: number image_file_size_limit: number diff --git a/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx index b0ebb3cf489..40a830a7ebe 100644 --- a/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx @@ -1,6 +1,7 @@ +import type { AppModelSelectionResponse } from '@dify/contracts/api/console/apps/types.gen' import type { SuggestedQuestionsAfterAnswer } from '@/app/components/base/features/types' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { CompletionParams, Model, ModelModeType } from '@/types/app' +import type { CompletionParams, ModelModeType } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Field, FieldItem } from '@langgenius/dify-ui/field' @@ -35,7 +36,7 @@ const DEFAULT_COMPLETION_PARAMS: CompletionParams = { const DEFAULT_FOLLOW_UP_PROMPT = `Please predict the three most likely follow-up questions a user would ask, keep each question under 20 characters, use the same language as the assistant's latest response, and output a JSON array like ["question1", "question2", "question3"].` const CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH = 1000 -const getInitialModel = (model?: Model): Model => ({ +const getInitialModel = (model?: AppModelSelectionResponse) => ({ provider: model?.provider || '', name: model?.name || '', mode: model?.mode || ModelModeTypeEnum.chat, @@ -53,7 +54,7 @@ type PromptMode = (typeof PROMPT_MODE)[keyof typeof PROMPT_MODE] const FollowUpSettingModal = ({ data, onSave, onCancel }: FollowUpSettingModalProps) => { const { t } = useTranslation(['appDebug', 'common']) - const [model, setModel] = useState(() => getInitialModel(data.model)) + const [model, setModel] = useState(() => getInitialModel(data.model)) const [prompt, setPrompt] = useState(data.prompt || '') const [promptMode, setPromptMode] = useState( data.prompt ? PROMPT_MODE.custom : PROMPT_MODE.default, @@ -61,7 +62,7 @@ const FollowUpSettingModal = ({ data, onSave, onCancel }: FollowUpSettingModalPr const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel( ModelTypeEnum.textGeneration, ) - const selectedModel = useMemo(() => { + const selectedModel = useMemo(() => { if (model.provider && model.name) return model if (!defaultModel) return model @@ -89,7 +90,7 @@ const FollowUpSettingModal = ({ data, onSave, onCancel }: FollowUpSettingModalPr (newParams: FormValue) => { setModel({ ...selectedModel, - completion_params: newParams as CompletionParams, + completion_params: newParams, }) }, [selectedModel], diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal-submission.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal-submission.spec.tsx new file mode 100644 index 00000000000..d539f41148f --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal-submission.spec.tsx @@ -0,0 +1,191 @@ +import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen' +import type { ModerationConfig } from '@/models/debug' +import type { ConsoleClient } from '@/service/console' +import { act, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import ModerationSettingModal from '../moderation-setting-modal' + +const { getExtensions, createExtension } = vi.hoisted(() => ({ + getExtensions: vi.fn(), + createExtension: vi.fn(), +})) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { createConsoleQuery } = await import('@/service/console/query-policies') + const consoleClient = new Proxy(actual.consoleClient, { + get(target, property, receiver) { + if (property === 'apiBasedExtension') { + return { + get: getExtensions, + post: createExtension, + byId: target.apiBasedExtension.byId, + } + } + return Reflect.get(target, property, receiver) + }, + }) + return { ...actual, consoleClient, consoleQuery: createConsoleQuery(consoleClient) } +}) + +vi.mock('@/service/use-common', () => ({ + useCodeBasedExtensions: () => ({ data: { data: [] } }), + useModelProviderDetails: () => ({ data: { data: [] }, isPending: false }), +})) + +vi.mock('nuqs', async (importOriginal) => ({ + ...(await importOriginal()), + useQueryState: () => [null, vi.fn()], +})) + +const existingExtension: ApiBasedExtensionResponse = { + id: 'existing-extension', + name: 'Existing API', + api_endpoint: 'https://existing.example.test', + api_key: 'existing-secret', +} +const newExtension: ApiBasedExtensionResponse = { + id: 'new-extension', + name: 'New API', + api_endpoint: 'https://new.example.test', + api_key: 'new-secret', +} +const moderation: ModerationConfig = { + enabled: true, + type: 'api', + config: { + api_based_extension_id: existingExtension.id, + inputs_config: { enabled: true }, + outputs_config: { enabled: false }, + }, +} +const childDialogName = 'common.apiBasedExtension.modal.title' +const outputLabel = 'appDebug.feature.moderation.modal.content.output' + +const renderModal = async () => { + const onSave = vi.fn() + const onCancel = vi.fn() + await act(async () => { + renderWithConsoleQuery( + , + ) + }) + return { onSave, onCancel } +} + +const openExtensionForm = async ( + user: ReturnType, + apiKey = 'new-secret', +) => { + await user.click(await screen.findByRole('button', { name: /Existing API/ })) + await user.click(await screen.findByRole('button', { name: 'common.operation.add' })) + const dialog = await screen.findByRole('dialog', { name: childDialogName }) + await user.type( + within(dialog).getByRole('textbox', { name: 'common.apiBasedExtension.modal.name.title' }), + newExtension.name, + ) + await user.type( + within(dialog).getByRole('textbox', { + name: 'common.apiBasedExtension.modal.apiEndpoint.title', + }), + newExtension.api_endpoint, + ) + await user.type( + within(dialog).getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiKey.title' }), + apiKey, + ) + return dialog +} + +beforeEach(() => { + getExtensions.mockResolvedValue([existingExtension]) + createExtension.mockReset() +}) + +it.each(['success', 'failure'] as const)( + 'preserves the moderation draft while a nested extension submission is pending and after %s', + async (outcome) => { + const user = userEvent.setup() + let resolveCreation!: (response: ApiBasedExtensionResponse) => void + let rejectCreation!: (error: Error) => void + const creation = new Promise((resolve, reject) => { + resolveCreation = resolve + rejectCreation = reject + }) + createExtension.mockReturnValue(creation) + const { onSave, onCancel } = await renderModal() + await user.click(screen.getByRole('switch', { name: outputLabel })) + const dialog = await openExtensionForm(user) + const childSave = within(dialog).getByRole('button', { name: 'common.operation.save' }) + + await user.click(childSave) + + await waitFor(() => expect(createExtension).toHaveBeenCalledTimes(1)) + expect(createExtension.mock.calls[0]?.[0]).toEqual({ + body: { + name: newExtension.name, + api_endpoint: newExtension.api_endpoint, + api_key: newExtension.api_key, + }, + }) + expect(childSave).toBeDisabled() + expect(onSave).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + + if (outcome === 'success') { + await act(async () => resolveCreation(newExtension)) + await waitFor(() => + expect(screen.queryByRole('dialog', { name: childDialogName })).not.toBeInTheDocument(), + ) + } else { + await act(async () => rejectCreation(new Error('Creation failed'))) + await waitFor(() => expect(childSave).toBeEnabled()) + expect(screen.getByRole('dialog', { name: childDialogName })).toBeInTheDocument() + await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' })) + } + + expect(onSave).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: /Existing API/ })).toBeInTheDocument() + expect(screen.getByRole('switch', { name: outputLabel })).toBeChecked() + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(onSave).toHaveBeenCalledExactlyOnceWith({ + ...moderation, + config: { + ...moderation.config, + outputs_config: { enabled: true, preset_response: '' }, + }, + }) + }, +) + +it('keeps nested extension validation and resubmission from submitting the moderation draft', async () => { + const user = userEvent.setup() + createExtension.mockResolvedValue(newExtension) + const { onSave, onCancel } = await renderModal() + const dialog = await openExtensionForm(user, 'a') + + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + + expect( + await within(dialog).findByText('common.apiBasedExtension.modal.apiKey.lengthError'), + ).toBeInTheDocument() + expect(createExtension).not.toHaveBeenCalled() + expect(onSave).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + const apiKey = within(dialog).getByRole('textbox', { + name: 'common.apiBasedExtension.modal.apiKey.title', + }) + await user.clear(apiKey) + await user.type(apiKey, newExtension.api_key) + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + + await waitFor(() => + expect(screen.queryByRole('dialog', { name: childDialogName })).not.toBeInTheDocument(), + ) + expect(createExtension).toHaveBeenCalledTimes(1) + expect(onSave).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: /Existing API/ })).toBeInTheDocument() +}) diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx index a41ce22b909..bec6a0864a9 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx @@ -1,9 +1,17 @@ +import type { AppConfigJsonValue } from '@dify/contracts/api/console/apps/types.gen' +import type { TFunction } from 'i18next' import type { ModerationConfig } from '@/models/debug' import { act, fireEvent, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as i18n from 'react-i18next' +import { buildConfigurationDatasetConfigs } from '@/app/components/app/configuration/hooks/configuration-lifecycle/dataset' +import { createPublishHandler } from '@/app/components/app/configuration/hooks/configuration-lifecycle/publish' +import { buildPublishedConfig } from '@/app/components/app/configuration/hooks/configuration-lifecycle/published-config' +import { createFeaturesStore } from '@/app/components/base/features/store' import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createAppModelConfigFixture } from '@/test/fixtures/app' import { withSelectorKey } from '@/test/i18n-mock' +import { AppModeEnum, ModelModeType } from '@/types/app' import ModerationSettingModal from '../moderation-setting-modal' const mockNotify = vi.fn() @@ -975,6 +983,144 @@ describe('ModerationSettingModal', () => { expect(screen.getByText(/apiBasedExtension\.link/))!.toBeInTheDocument() }) + it.each<{ + scenario: string + initialConfig: NonNullable + clear: boolean + expectedValue?: AppConfigJsonValue + displayedValue?: string + }>([ + { scenario: 'without editing an absent optional value', initialConfig: {}, clear: false }, + { + scenario: 'after explicitly clearing an optional value', + initialConfig: { optional_token: 'old-token' }, + clear: true, + expectedValue: '', + }, + { + scenario: 'without editing a null extension value', + initialConfig: { optional_token: null }, + clear: false, + expectedValue: null, + }, + { + scenario: 'without editing a false extension value', + initialConfig: { optional_token: false }, + clear: false, + expectedValue: false, + }, + { + scenario: 'without editing a zero extension value', + initialConfig: { optional_token: 0 }, + clear: false, + expectedValue: 0, + displayedValue: '0', + }, + { + scenario: 'without editing a numeric extension value', + initialConfig: { optional_token: 42 }, + clear: false, + expectedValue: 42, + displayedValue: '42', + }, + ])( + 'publishes a saved custom moderation configuration $scenario', + async ({ initialConfig, clear, expectedValue, displayedValue }) => { + const user = userEvent.setup() + mockCodeBasedExtensions = { + data: { + data: [ + { + name: 'custom-ext', + label: { 'en-US': 'Custom Extension', 'zh-Hans': '่‡ชๅฎšไน‰ๆ‰ฉๅฑ•' }, + form_schema: [ + { + variable: 'optional_token', + label: { 'en-US': 'Optional token', 'zh-Hans': 'ๅฏ้€‰ไปค็‰Œ' }, + type: 'text-input', + required: false, + default: '', + placeholder: 'Optional token', + options: [], + }, + ], + }, + ], + }, + } + const featuresStore = createFeaturesStore() + await renderModal( + { + const { features, setFeatures } = featuresStore.getState() + setFeatures({ ...features, moderation }) + }} + />, + ) + const optionalToken = screen.getByPlaceholderText('Optional token') + if (clear) await user.clear(optionalToken) + else expect(optionalToken).toHaveValue(displayedValue ?? '') + + if (clear) await user.keyboard('{Enter}') + else await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + const backendModelConfig = createAppModelConfigFixture({ + model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} }, + }) + const config = buildPublishedConfig({ + backendModelConfig, + collectionList: [], + datasetConfigs: buildConfigurationDatasetConfigs({ backendModelConfig, nextDataSets: [] }), + mode: AppModeEnum.CHAT, + nextDataSets: [], + }) + const publish = createPublishHandler({ + appId: 'app-1', + chatPromptConfig: config.chatPromptConfig, + completionParamsState: config.completionParams, + completionPromptConfig: config.completionPromptConfig, + contextVarEmpty: false, + dataSets: [], + datasetConfigs: config.datasetConfigs, + externalDataToolsConfig: [], + hasSetBlockStatus: { history: true, query: true }, + isAdvancedMode: false, + isFunctionCall: false, + mode: AppModeEnum.CHAT, + modelConfig: config.modelConfig, + promptEmpty: false, + promptMode: 'simple', + resolvedModelModeType: ModelModeType.chat, + setCanReturnToSimpleMode: vi.fn(), + setPublishedConfig: vi.fn(), + t: withSelectorKey((key: string) => key) as TFunction<['appDebug', 'common']>, + }) + const updateModelConfig = vi.fn[0]>().mockResolvedValue(undefined) + await publish(updateModelConfig, undefined, featuresStore.getState().features) + + expect(updateModelConfig).toHaveBeenCalledTimes(1) + const moderation = updateModelConfig.mock.calls[0]?.[0].body.sensitive_word_avoidance + expect(moderation).toMatchObject({ + enabled: true, + type: 'custom-ext', + config: { inputs_config: { enabled: true, preset_response: 'Blocked' } }, + }) + if (expectedValue !== undefined) + expect(moderation?.config).toHaveProperty('optional_token', expectedValue) + else expect(moderation?.config).not.toHaveProperty('optional_token') + }, + ) + it('should fallback missing inputs_config to disabled in formatted save data', async () => { await renderModal( = ({ forms, value, onChange }) => const locale = useLocale() const handleFormChange = (type: string, v: string) => { - onChange({ ...value, [type]: v }) + onChange({ [type]: v }) } return ( <> - {forms.map((form, index) => { + {forms.map((form) => { + const fieldValue = value?.[form.variable] + const inputValue = + typeof fieldValue === 'string' || typeof fieldValue === 'number' ? fieldValue : '' const selectOptions = form.type === 'select' ? form.options.map((option) => ({ @@ -38,13 +41,14 @@ const FormGeneration: FC = ({ forms, value, onChange }) => selectOptions.find((option) => option.value === value?.[form.variable]) ?? null return ( -
+
{locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
{form.type === 'text-input' && ( handleFormChange(form.variable, e.target.value)} @@ -55,7 +59,7 @@ const FormGeneration: FC = ({ forms, value, onChange }) =>