diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index c740319be0e..7bb01c2b98b 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -181,17 +181,6 @@ "count": 2 } }, - "web/app/account/(commonLayout)/account-page/index.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 2 - }, - "no-restricted-imports": { - "count": 1 - } - }, "web/app/account/(commonLayout)/delete-account/components/verify-email.tsx": { "eslint-react/set-state-in-effect": { "count": 1 @@ -437,20 +426,6 @@ "count": 4 } }, - "web/app/components/app/configuration/config/automatic/get-automatic-res.tsx": { - "eslint-react/set-state-in-effect": { - "count": 4 - }, - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - }, - "typescript/no-explicit-any": { - "count": 1 - } - }, "web/app/components/app/configuration/config/automatic/idea-output.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -479,14 +454,6 @@ "count": 1 } }, - "web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx": { - "eslint-react/set-state-in-effect": { - "count": 4 - }, - "typescript/no-explicit-any": { - "count": 2 - } - }, "web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -592,11 +559,6 @@ "count": 1 } }, - "web/app/components/app/create-app-dialog/app-list/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/create-app-dialog/app-list/sidebar.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -665,14 +627,6 @@ "count": 1 } }, - "web/app/components/app/switch-app-modal/index.tsx": { - "eslint-react/set-state-in-effect": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/text-generate/item/index.tsx": { "typescript/no-explicit-any": { "count": 3 @@ -709,11 +663,6 @@ "count": 2 } }, - "web/app/components/apps/app-card.tsx": { - "jsx_a11y/no-noninteractive-element-to-interactive-role": { - "count": 1 - } - }, "web/app/components/apps/import-from-marketplace-template-modal.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -5859,11 +5808,6 @@ "count": 1 } }, - "web/models/app.ts": { - "erasable-syntax-only/enums": { - "count": 2 - } - }, "web/models/datasets.ts": { "erasable-syntax-only/enums": { "count": 7 @@ -5954,7 +5898,7 @@ "count": 1 }, "typescript/no-explicit-any": { - "count": 6 + "count": 4 } }, "web/service/base.ts": { @@ -5983,7 +5927,7 @@ "count": 1 }, "typescript/no-explicit-any": { - "count": 5 + "count": 3 } }, "web/service/debug.ts": { @@ -6088,11 +6032,6 @@ "count": 1 } }, - "web/service/use-apps.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/use-datasource.ts": { "no-restricted-imports": { "count": 1 diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx index 04c3c07986e..fe56218e125 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx @@ -17,7 +17,25 @@ const mockUpdateAppSiteStatus = vi.hoisted(() => vi.fn()) const mockUpdateAppSiteConfig = vi.hoisted(() => vi.fn()) const mockUpdateAppSiteAccessToken = vi.hoisted(() => vi.fn()) const mockFetchAppDetail = vi.hoisted(() => vi.fn()) -const mockSetQueryData = vi.hoisted(() => vi.fn()) +const mockInvalidateQueries = vi.hoisted(() => vi.fn()) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), + } +}) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + apps: { + get: { key: () => ['console', 'apps', 'get'] }, + starred: { get: { key: () => ['console', 'apps', 'starred', 'get'] } }, + recent: { get: { key: () => ['console', 'apps', 'recent', 'get'] } }, + }, + }, +})) vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: typeof mockAppState) => T): T => selector(mockAppState), @@ -34,17 +52,6 @@ vi.mock('@/service/apps', () => ({ updateAppSiteAccessToken: (...args: unknown[]) => mockUpdateAppSiteAccessToken(...args), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - - return { - ...actual, - useQueryClient: () => ({ - setQueryData: mockSetQueryData, - }), - } -}) - vi.mock('@/context/account-state', async () => { const { createAccountStateModuleMock } = await import('@/test/console/state-fixture') return createAccountStateModuleMock(() => ({ @@ -188,16 +195,19 @@ describe('CardView ACL edit guards', () => { expect(mockUpdateAppSiteAccessToken).toHaveBeenCalledWith({ url: '/apps/app-1/site/access-token-reset', }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['console', 'apps', 'get'], + }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['console', 'apps', 'starred', 'get'], + }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['console', 'apps', 'recent', 'get'], + }) await waitFor(() => { expect(mockFetchAppDetail).toHaveBeenCalled() }) expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) - expect(mockSetQueryData).toHaveBeenCalledWith( - ['apps', 'detail', 'app-1'], - expect.objectContaining({ - site: expect.objectContaining({ title: 'Saved site title' }), - }), - ) expect(mockAppState.setAppDetail).toHaveBeenCalledWith( expect.objectContaining({ site: expect.objectContaining({ title: 'Saved site title' }), diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx index a5e0856452d..c5b0b34ea51 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx @@ -13,7 +13,6 @@ import { useTranslation } from 'react-i18next' import AppCard from '@/app/components/app/overview/app-card' import TriggerCard from '@/app/components/app/overview/trigger-card' import { useStore as useAppStore } from '@/app/components/app/store' -import { useSetNeedRefreshAppList } from '@/app/components/apps/storage' import Loading from '@/app/components/base/loading' import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' @@ -27,7 +26,7 @@ import { updateAppSiteConfig, updateAppSiteStatus, } from '@/service/apps' -import { appDetailQueryKeyPrefix } from '@/service/use-apps' +import { consoleQuery } from '@/service/client' import { useAppWorkflow } from '@/service/use-workflow' import { AppModeEnum } from '@/types/app' import { asyncRunSafe } from '@/utils' @@ -96,17 +95,14 @@ const CardView: FC = ({ appId, isInPanel, className }) => { ? buildTriggerModeMessage(t(($) => $['mcp.server.title'], { ns: 'tools' })) : null - const setNeedRefresh = useSetNeedRefreshAppList() - const updateAppDetail = useCallback(async () => { try { const res = await fetchAppDetail({ url: '/apps', id: appId }) - queryClient.setQueryData([...appDetailQueryKeyPrefix, appId], res) setAppDetail({ ...res }) } catch (error) { console.error(error) } - }, [appId, queryClient, setAppDetail]) + }, [appId, setAppDetail]) const handleCallbackResult = ( err: Error | null, @@ -184,8 +180,11 @@ const CardView: FC = ({ appId, isInPanel, className }) => { body: params, }) as Promise, ) - if (!err) setNeedRefresh('1') - + if (!err) { + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() }) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() }) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() }) + } handleCallbackResult(err) } diff --git a/web/app/(commonLayout)/apps/page.tsx b/web/app/(commonLayout)/apps/page.tsx index 52d908114cd..8651d0ed69d 100644 --- a/web/app/(commonLayout)/apps/page.tsx +++ b/web/app/(commonLayout)/apps/page.tsx @@ -1,7 +1,5 @@ -import Apps from '@/app/components/apps' +import { Apps } from '@/app/components/apps' -const AppList = () => { +export default function AppsPage() { return } - -export default AppList diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index 80d3a82e6eb..9a4f3ea8626 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -1,15 +1,15 @@ 'use client' +import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen' import type { IItem } from '@/app/components/header/account-setting/collapse' -import type { App } from '@/types/app' +import { zIconType } from '@dify/contracts/api/console/apps/zod.gen' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Input } from '@langgenius/dify-ui/input' import { toast } from '@langgenius/dify-ui/toast' -import { RiGraduationCapFill } from '@remixicon/react' import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' -import Input from '@/app/components/base/input' import PremiumBadge from '@/app/components/base/premium-badge' import Collapse from '@/app/components/header/account-setting/collapse' import { validPassword } from '@/config' @@ -18,7 +18,6 @@ import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { consoleQuery } from '@/service/client' import { updateUserProfile } from '@/service/common' -import { normalizeAppPagination } from '@/service/use-apps' import DeleteAccount from '../delete-account' import AvatarWithEdit from './AvatarWithEdit' import EmailChangeModal from './email-change-modal' @@ -29,6 +28,7 @@ const titleClassName = ` const descriptionClassName = ` mt-1 body-xs-regular text-text-tertiary ` +type AccountAppItem = AppPartial & IItem export default function AccountPage() { const { t } = useTranslation() @@ -42,7 +42,6 @@ export default function AccountPage() { name: '', }, }, - select: normalizeAppPagination, }), ) const apps = appList?.data || [] @@ -134,18 +133,17 @@ export default function AccountPage() { } } - const renderAppItem = (item: IItem) => { - const { icon, icon_background, icon_type, icon_url } = item as IItem & - Pick + const renderAppItem = (item: AccountAppItem) => { + const appIconType = zIconType.safeParse(item.icon_type).data ?? null return (
{item.name}
@@ -172,7 +170,7 @@ export default function AccountPage() { {userProfile.name} {isEducationAccount && ( - )} @@ -186,12 +184,13 @@ export default function AccountPage() {
{userProfile.name}
-
{t(($) => $['operation.edit'], { ns: 'common' })} -
+
@@ -201,12 +200,13 @@ export default function AccountPage() { {userProfile.email}
{systemFeatures.enable_change_email && ( -
setShowUpdateEmail(true)} > {t(($) => $['operation.change'], { ns: 'common' })} -
+ )} @@ -238,7 +238,7 @@ export default function AccountPage() { {!!apps.length && ( $['account.showAppLength'], { ns: 'common', length: apps.length })}`} - items={apps.map((app: App) => ({ ...app, key: app.id, name: app.name }))} + items={apps.map((app) => ({ ...app, key: app.id, name: app.name }))} renderItem={renderAppItem} wrapperClassName="mt-2" /> 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 b5b96e907b0..2f6f6f8080b 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,4 +1,5 @@ import { act, renderHook } from '@testing-library/react' +import { consoleQuery } from '@/service/client' import { AppModeEnum } from '@/types/app' import { useAppInfoActions } from '../use-app-info-actions' @@ -16,7 +17,7 @@ const toastMocks = vi.hoisted(() => { }) const mockReplace = vi.fn() const mockOnPlanInfoChanged = vi.fn() -const mockInvalidateAppList = vi.fn() +const mockInvalidateQueries = vi.fn() const mockSetAppDetail = vi.fn() const mockUpdateAppInfo = vi.fn() const mockCopyApp = vi.fn() @@ -78,17 +79,13 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }), })) -vi.mock('@/service/use-apps', () => ({ - appDetailQueryKeyPrefix: ['apps', 'detail'], - useInvalidateAppList: () => mockInvalidateAppList, -})) - vi.mock('@tanstack/react-query', () => ({ queryOptions: (options: TOptions) => options, useSuspenseQuery: () => ({ data: { rbac_enabled: true }, }), useQueryClient: () => ({ + invalidateQueries: mockInvalidateQueries, setQueryData: mockSetQueryData, }), })) @@ -125,7 +122,6 @@ describe('useAppInfoActions', () => { mockExportWorkflowAppDsl.mockResolvedValue({ status: 'downloaded' }) mockOnAppMetaUpdate.mockReturnValue(() => {}) mockGetSocket.mockReturnValue(null) - mockSetQueryData.mockReset() mockAppDetail = { id: 'app-1', name: 'Test App', @@ -244,6 +240,25 @@ 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' })).toEqual( + expect.objectContaining({ id: 'app-1', name: 'Updated' }), + ) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.apps.get.key(), + }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.apps.starred.get.key(), + }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.apps.recent.get.key(), + }) expect(mockSetAppDetail).toHaveBeenCalledWith(updatedApp) expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.editDone' }) }) @@ -333,6 +348,7 @@ describe('useAppInfoActions', () => { }) expect(mockCopyApp).toHaveBeenCalled() + expect(mockInvalidateQueries).toHaveBeenCalledTimes(3) expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated', @@ -519,7 +535,7 @@ describe('useAppInfoActions', () => { expect(mockDeleteApp).toHaveBeenCalledWith('app-1') expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.appDeleted' }) - expect(mockInvalidateAppList).toHaveBeenCalled() + expect(mockInvalidateQueries).toHaveBeenCalledTimes(3) expect(mockReplace).toHaveBeenCalledWith('/apps') expect(mockSetAppDetail).toHaveBeenCalledWith() }) @@ -572,6 +588,16 @@ describe('useAppInfoActions', () => { }) 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) unmount() 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 5d0f7e6eb91..74135577262 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 @@ -95,13 +95,7 @@ const AppInfoModals = ({ return ( <> {activeModal === 'switch' && ( - + )} {activeModal === 'edit' && ( + +const updateCachedAppMetadata = (cachedApp: AppDetailWithSite | undefined, app: AppMetadata) => { + if (!cachedApp) return cachedApp + + return { + ...cachedApp, + description: app.description, + icon: app.icon, + icon_background: app.icon_background, + icon_type: app.icon_type, + icon_url: app.icon_url, + max_active_requests: app.max_active_requests, + name: app.name, + updated_at: app.updated_at, + use_icon_as_answer_icon: app.use_icon_as_answer_icon, + } +} + const createInitialUiState = (resetKey?: string): AppInfoUiState => ({ resetKey, panelOpen: false, @@ -62,7 +95,6 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction const { onPlanInfoChanged } = useProviderContext() const appDetail = useAppStore((state) => state.appDetail) const setAppDetail = useAppStore((state) => state.setAppDetail) - const invalidateAppList = useInvalidateAppList() const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl() const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } = useExportWorkflowAppDsl() const isExporting = isAppDslExporting || isWorkflowAppDslExporting @@ -131,8 +163,6 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction setActiveModal(null) }, [setActiveModal]) - const setNeedRefresh = useSetNeedRefreshAppList() - const emitAppMetaUpdate = useCallback(() => { if (!appDetail?.id) return @@ -163,7 +193,15 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction try { const res = await fetchAppDetail({ url: '/apps', id: appDetail.id }) if (disposed) return - queryClient.setQueryData([...appDetailQueryKeyPrefix, appDetail.id], res) + queryClient.setQueryData( + consoleQuery.apps.byAppId.get.queryKey({ + input: { params: { app_id: appDetail.id } }, + }), + (cachedApp) => updateCachedAppMetadata(cachedApp, res), + ) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() }) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() }) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() }) setAppDetail({ ...res }) } catch (error) { console.error('failed to refresh app detail from collaboration update:', error) @@ -205,7 +243,15 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction t(($) => $.editDone, { ns: 'app' }), { type: 'success' }, ) - queryClient.setQueryData([...appDetailQueryKeyPrefix, app.id], app) + queryClient.setQueryData( + consoleQuery.apps.byAppId.get.queryKey({ + input: { params: { app_id: appDetail.id } }, + }), + (cachedApp) => updateCachedAppMetadata(cachedApp, app), + ) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() }) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() }) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() }) setAppDetail(app) emitAppMetaUpdate() } catch { @@ -215,7 +261,7 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction ) } }, - [appDetail, closeModal, queryClient, setAppDetail, t, emitAppMetaUpdate], + [appDetail, closeModal, setAppDetail, t, emitAppMetaUpdate, queryClient], ) const onCopy: DuplicateAppModalProps['onConfirm'] = useCallback( @@ -235,7 +281,9 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction t(($) => $['newApp.appCreated'], { ns: 'app' }), { type: 'success' }, ) - setNeedRefresh('1') + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() }) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() }) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() }) onPlanInfoChanged() getRedirection(newApp, replace, { isRbacEnabled }) } catch { @@ -245,7 +293,7 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction ) } }, - [appDetail, closeModal, isRbacEnabled, onPlanInfoChanged, replace, setNeedRefresh, t], + [appDetail, closeModal, isRbacEnabled, onPlanInfoChanged, queryClient, replace, t], ) const onExport = useCallback( @@ -287,7 +335,9 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction t(($) => $.appDeleted, { ns: 'app' }), { type: 'success' }, ) - invalidateAppList() + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() }) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() }) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() }) onPlanInfoChanged() setAppDetail() replace('/apps') @@ -298,7 +348,7 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction ) } closeModal() - }, [appDetail, closeModal, invalidateAppList, onPlanInfoChanged, replace, setAppDetail, t]) + }, [appDetail, closeModal, onPlanInfoChanged, queryClient, replace, setAppDetail, t]) return { appDetail, diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index 89a2bac6574..2e94a4be844 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -1,16 +1,15 @@ 'use client' +import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen' import type { Subject } from '@/models/access-control' -import type { App } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' import { RadioGroup } from '@langgenius/dify-ui/radio' import { toast } from '@langgenius/dify-ui/toast' -import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react' import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useEffect, useId } from 'react' import { useTranslation } from 'react-i18next' import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { AccessMode, SubjectType } from '@/models/access-control' +import { AccessMode, isAccessMode, SubjectType } from '@/models/access-control' import { consoleQuery } from '@/service/client' import useAccessControlStore from '../../../../context/access-control-store' import { Infotip } from '../../base/infotip' @@ -19,14 +18,15 @@ import AccessControlItem from './access-control-item' import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members' type AccessControlProps = { - app: Pick + app: Pick onClose: () => void onConfirm?: () => void } export default function AccessControl(props: AccessControlProps) { const { app, onClose, onConfirm } = props - const { id: appId, access_mode: appAccessMode } = app + const { id: appId } = app + const appAccessMode = isAccessMode(app.access_mode) ? app.access_mode : undefined const accessControlOptionsLabelId = useId() const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) @@ -109,7 +109,7 @@ export default function AccessControl(props: AccessControlProps) {
- +

{t(($) => $['accessControlDialog.accessItems.organization'], { ns: 'app' })}

@@ -122,7 +122,7 @@ export default function AccessControl(props: AccessControlProps) {
- +

{t(($) => $['accessControlDialog.accessItems.external'], { ns: 'app' })}

@@ -132,7 +132,7 @@ export default function AccessControl(props: AccessControlProps) {
- +

{t(($) => $['accessControlDialog.accessItems.anyone'], { ns: 'app' })}

diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index 1b1a82991bd..1a476c41a10 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -415,8 +415,7 @@ describe('AppPublisher', () => { }) it('should refresh app detail after access control confirmation', async () => { - const { queryClient } = render() - const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData') + render() fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText('publisher-access-control')) @@ -428,12 +427,6 @@ describe('AppPublisher', () => { await waitFor(() => { expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) }) - expect(setQueryDataSpy).toHaveBeenCalledWith( - ['apps', 'detail', 'app-1'], - expect.objectContaining({ - access_mode: AccessMode.PUBLIC, - }), - ) expect(mockSetAppDetail).toHaveBeenCalledWith( expect.objectContaining({ access_mode: AccessMode.PUBLIC, diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index e3735481f9d..2485c78a0e2 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -11,7 +11,7 @@ import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' import { useHotkey } from '@tanstack/react-hotkeys' -import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' +import { useSuspenseQuery } from '@tanstack/react-query' import { use, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections' @@ -38,7 +38,6 @@ import { AccessMode } from '@/models/access-control' import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' import { fetchAppDetail, publishToCreatorsPlatform } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' -import { appDetailQueryKeyPrefix } from '@/service/use-apps' import { useInvalidateAppWorkflow } from '@/service/use-workflow' import { fetchPublishedWorkflow } from '@/service/workflow' import { AppModeEnum } from '@/types/app' @@ -129,15 +128,16 @@ export function AppPublisher({ const appDetail = useAppStore((state) => state.appDetail) const setAppDetail = useAppStore((state) => state.setAppDetail) const canManageTools = useCanManageTools() - const queryClient = useQueryClient() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { formatTimeFromNow } = useFormatTimeFromNow() const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode }) - const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes( - appDetail?.mode || AppModeEnum.CHAT, - ) + const appMode = appDetail?.mode || AppModeEnum.CHAT + const isChatApp = + appMode === AppModeEnum.CHAT || + appMode === AppModeEnum.AGENT_CHAT || + appMode === AppModeEnum.COMPLETION const hiddenLaunchVariables: WorkflowHiddenStartVariable[] = (inputs ?? []).filter( (input) => input.hide === true, ) @@ -258,7 +258,6 @@ export function AppPublisher({ if (!appDetail) return try { const res = await fetchAppDetail({ url: '/apps', id: appDetail.id }) - queryClient.setQueryData([...appDetailQueryKeyPrefix, appDetail.id], res) setAppDetail({ ...res }) } finally { setShowAppAccessControl(false) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx index 6c78a82446d..6ff709a6eaf 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx @@ -26,10 +26,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () }), })) -vi.mock('@/service/use-apps', () => ({ - useGenerateRuleTemplate: () => ({ - data: mockInstructionTemplate, - }), +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useQuery: () => ({ data: mockInstructionTemplate }), })) vi.mock('@/service/debug', () => ({ @@ -80,6 +79,7 @@ vi.mock('../instruction-editor-in-workflow', () => ({
{value}
+
), })) @@ -129,9 +129,7 @@ describe('GetAutomaticRes', () => { mockInstructionTemplate = undefined }) - it('should initialize from template suggestions and persist model updates', async () => { - mockInstructionTemplate = { data: 'template instruction' } - + it('should apply a basic suggestion and persist model updates', async () => { render( { />, ) - await waitFor(() => { - expect(screen.getByTestId('basic-editor')).toHaveTextContent('template instruction') - }) - fireEvent.click(screen.getByText(/(?:^|\.)generate\.template\.pythonDebugger\.name(?=$|:)/)) await waitFor(() => { @@ -162,6 +156,32 @@ describe('GetAutomaticRes', () => { expect(localStorage.getItem('auto-gen-model')).toContain('"temperature":0.3') }) + it('should preserve an intentionally cleared template instruction', async () => { + mockInstructionTemplate = { data: 'template instruction' } + + render( + , + ) + + await waitFor(() => { + expect(screen.getByTestId('workflow-editor')).toHaveTextContent('template instruction') + }) + fireEvent.click(screen.getByText('clear-workflow-instruction')) + fireEvent.click(screen.getByText(/(?:^|\.)generate\.generate(?=$|:)/)) + + expect(screen.getByTestId('workflow-editor')).toBeEmptyDOMElement() + expect(mockToastError).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)errorMsg\.fieldRequired(?=$|:)/), + ) + expect(mockGenerateRule).not.toHaveBeenCalled() + }) + it('should block generation when instruction is empty', () => { render( void -}> = ({ Icon, text, onClick }) => { +}> = ({ iconClassName, text, onClick }) => { return ( -
- +
{text}
-
+ ) } @@ -91,60 +81,64 @@ const GetAutomaticRes: FC = ({ }) => { const { t } = useTranslation() const [storedModel, setStoredModel] = useAutoGenModel() - const [model, setModel] = React.useState( - storedModel || { - name: '', - provider: '', - mode: mode as unknown as ModelModeType, - completion_params: {} as CompletionParams, - }, - ) + const [selectedModel, setSelectedModel] = React.useState() const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel( ModelTypeEnum.textGeneration, ) + const model = useMemo(() => { + if (selectedModel) return selectedModel + if (storedModel) return storedModel + + return { + name: defaultModel?.model ?? '', + provider: defaultModel?.provider.provider ?? '', + mode: mode as unknown as ModelModeType, + completion_params: {} as CompletionParams, + } + }, [defaultModel, mode, selectedModel, storedModel]) const tryList = [ { - icon: RiTerminalBoxLine, + iconClassName: 'i-ri-terminal-box-line', key: 'pythonDebugger', }, { - icon: RiTranslate, + iconClassName: 'i-ri-translate', key: 'translation', }, { - icon: RiPresentationLine, + iconClassName: 'i-ri-presentation-line', key: 'meetingTakeaways', }, { - icon: RiNewspaperLine, + iconClassName: 'i-ri-newspaper-line', key: 'writingsPolisher', }, { - icon: RiUser2Line, + iconClassName: 'i-ri-user-2-line', key: 'professionalAnalyst', }, { - icon: RiFileExcel2Line, + iconClassName: 'i-ri-file-excel-2-line', key: 'excelFormulaExpert', }, { - icon: RiRoadMapLine, + iconClassName: 'i-ri-road-map-line', key: 'travelPlanning', }, { - icon: RiDatabase2Line, + iconClassName: 'i-ri-database-2-line', key: 'SQLSorcerer', }, { - icon: RiGitCommitLine, + iconClassName: 'i-ri-git-commit-line', key: 'GitGud', }, ] as const - const [instructionFromSessionStorage, setInstruction] = useSessionStorageState( - `improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`, - ) - const instruction = instructionFromSessionStorage || '' + const [instructionFromSessionStorage, setInstructionFromSessionStorage] = + useSessionStorageState( + `improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`, + ) const [ideaOutput, setIdeaOutput] = useState('') type TemplateKey = (typeof tryList)[number]['key'] @@ -156,19 +150,22 @@ const GetAutomaticRes: FC = ({ const template = t(($) => $[`generate.template.${key}.instruction` as const], { ns: 'appDebug', }) - setInstruction(template) + setInstructionFromSessionStorage(template) setEditorKey(`${flowId}-${Date.now()}`) } }, - [t], + [flowId, setInstructionFromSessionStorage, t], ) - const { data: instructionTemplate } = useGenerateRuleTemplate(GeneratorType.prompt, isBasicMode) - useEffect(() => { - if (!instruction && instructionTemplate) setInstruction(instructionTemplate.data) - - setEditorKey(`${flowId}-${Date.now()}`) - }, [instructionTemplate]) + const { data: instructionTemplate } = useQuery({ + ...consoleQuery.instructionGenerate.template.post.queryOptions({ + input: { body: { type: GeneratorType.prompt } }, + }), + enabled: !isBasicMode, + retry: 0, + }) + const instruction = instructionFromSessionStorage ?? instructionTemplate?.data ?? '' + const instructionEditorKey = `${editorKey}-${instructionTemplate ? 'template' : 'pending'}` const isValid = () => { if (instruction.trim() === '') { @@ -190,20 +187,6 @@ const GetAutomaticRes: FC = ({ }, ) - useEffect(() => { - if (defaultModel) { - if (storedModel) { - setModel(storedModel) - } else { - setModel((prev) => ({ - ...prev, - name: defaultModel.model, - provider: defaultModel.provider.provider, - })) - } - } - }, [defaultModel, storedModel]) - const renderLoading = (
@@ -221,10 +204,10 @@ const GetAutomaticRes: FC = ({ name: newValue.modelId, mode: newValue.mode as ModelModeType, } - setModel(newModel) + setSelectedModel(newModel) setStoredModel(newModel) }, - [model, setModel, setStoredModel], + [model, setStoredModel], ) const handleCompletionParamsChange = useCallback( @@ -233,10 +216,10 @@ const GetAutomaticRes: FC = ({ ...model, completion_params: newParams as CompletionParams, } - setModel(newModel) + setSelectedModel(newModel) setStoredModel(newModel) }, - [model, setModel, setStoredModel], + [model, setStoredModel], ) const onGenerate = async () => { @@ -338,7 +321,7 @@ const GetAutomaticRes: FC = ({ {tryList.map((item) => ( $[`generate.template.${item.key}.name`], { ns: 'appDebug' })} onClick={handleChooseTemplate(item.key)} /> @@ -355,10 +338,10 @@ const GetAutomaticRes: FC = ({
{isBasicMode ? ( = ({ /> ) : ( @@ -387,7 +370,7 @@ const GetAutomaticRes: FC = ({ onClick={onGenerate} disabled={isLoading} > - + {t(($) => $['generate.generate'], { ns: 'appDebug' })} diff --git a/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx b/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx index 75fb1d1e335..29a4df0be15 100644 --- a/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx +++ b/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx @@ -26,10 +26,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () }), })) -vi.mock('@/service/use-apps', () => ({ - useGenerateRuleTemplate: () => ({ - data: mockInstructionTemplate, - }), +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useQuery: () => ({ data: mockInstructionTemplate }), })) vi.mock('@/service/debug', () => ({ @@ -70,6 +69,7 @@ vi.mock('../../automatic/instruction-editor-in-workflow', () => ({
{value}
+
), })) @@ -146,6 +146,35 @@ describe('GetCodeGeneratorResModal', () => { expect(localStorage.getItem('auto-gen-model')).toContain('"temperature":0.2') }) + it('should preserve an intentionally cleared template instruction', async () => { + mockInstructionTemplate = { data: 'code template' } + + render( + , + ) + + await waitFor(() => { + expect(screen.getByTestId('workflow-editor')).toHaveTextContent('code template') + }) + fireEvent.click(screen.getByText('clear-code-instruction')) + fireEvent.click(screen.getByText(/(?:^|\.)codegen\.generate(?=$|:)/)) + + expect(screen.getByTestId('workflow-editor')).toBeEmptyDOMElement() + expect(mockToastError).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)errorMsg\.fieldRequired(?=$|:)/), + ) + expect(mockGenerateRule).not.toHaveBeenCalled() + }) + it('should block generation when instruction is empty', () => { render( = ({ }) => { const { t } = useTranslation() const [storedModel, setStoredModel] = useAutoGenModel() - const [model, setModel] = React.useState( - storedModel || { - name: '', - provider: '', - mode: mode as unknown as ModelModeType, - completion_params: defaultCompletionParams, - }, - ) + const [selectedModel, setSelectedModel] = React.useState() const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel( ModelTypeEnum.textGeneration, ) - const [instructionFromSessionStorage, setInstruction] = useSessionStorageState( - `improve-instruction-${flowId}-${nodeId}`, - ) - const instruction = instructionFromSessionStorage || '' + const model = useMemo(() => { + if (selectedModel) return selectedModel + if (storedModel) { + return { + ...storedModel, + completion_params: { + ...defaultCompletionParams, + ...storedModel.completion_params, + }, + } + } + + return { + name: defaultModel?.model ?? '', + provider: defaultModel?.provider.provider ?? '', + mode: mode as unknown as ModelModeType, + completion_params: defaultCompletionParams, + } + }, [defaultModel, mode, selectedModel, storedModel]) + const [instructionFromSessionStorage, setInstructionFromSessionStorage] = + useSessionStorageState(`improve-instruction-${flowId}-${nodeId}`) const [ideaOutput, setIdeaOutput] = useState('') @@ -95,13 +105,15 @@ export const GetCodeGeneratorResModal: FC = ({ storageKey, }, ) - const [editorKey, setEditorKey] = useState(`${flowId}-0`) - const { data: instructionTemplate } = useGenerateRuleTemplate(GeneratorType.code) - useEffect(() => { - if (!instruction && instructionTemplate) setInstruction(instructionTemplate.data) - - setEditorKey(`${flowId}-${Date.now()}`) - }, [instructionTemplate]) + const [editorKey] = useState(`${flowId}-0`) + const { data: instructionTemplate } = useQuery({ + ...consoleQuery.instructionGenerate.template.post.queryOptions({ + input: { body: { type: GeneratorType.code } }, + }), + retry: 0, + }) + const instruction = instructionFromSessionStorage ?? instructionTemplate?.data ?? '' + const instructionEditorKey = `${editorKey}-${instructionTemplate ? 'template' : 'pending'}` const isValid = () => { if (instruction.trim() === '') { @@ -124,10 +136,10 @@ export const GetCodeGeneratorResModal: FC = ({ name: newValue.modelId, mode: newValue.mode as ModelModeType, } - setModel(newModel) + setSelectedModel(newModel) setStoredModel(newModel) }, - [model, setModel, setStoredModel], + [model, setStoredModel], ) const handleCompletionParamsChange = useCallback( @@ -136,10 +148,10 @@ export const GetCodeGeneratorResModal: FC = ({ ...model, completion_params: newParams as CompletionParams, } - setModel(newModel) + setSelectedModel(newModel) setStoredModel(newModel) }, - [model, setModel, setStoredModel], + [model, setStoredModel], ) const onGenerate = async () => { @@ -156,9 +168,9 @@ export const GetCodeGeneratorResModal: FC = ({ ideal_output: ideaOutput, language: languageMap[codeLanguages] || 'javascript', }) - if ((res as any).code) + if ('code' in res && typeof res.code === 'string') // not current or current is the same as the template would return a code field - res.modified = (res as any).code + res.modified = res.code if (error) { toast.error(error) @@ -175,26 +187,6 @@ export const GetCodeGeneratorResModal: FC = ({ { setTrue: showConfirmOverwrite, setFalse: hideShowConfirmOverwrite }, ] = useBoolean(false) - useEffect(() => { - if (defaultModel) { - if (storedModel) { - setModel({ - ...storedModel, - completion_params: { - ...defaultCompletionParams, - ...storedModel.completion_params, - }, - }) - } else { - setModel((prev) => ({ - ...prev, - name: defaultModel.model, - provider: defaultModel.provider.provider, - })) - } - } - }, [defaultModel, storedModel]) - const renderLoading = (
@@ -240,9 +232,9 @@ export const GetCodeGeneratorResModal: FC = ({ {t(($) => $['codegen.instruction'], { ns: 'appDebug' })}
= ({ onClick={onGenerate} disabled={isLoading} > - + {t(($) => $['codegen.generate'], { ns: 'appDebug' })} diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx index 469658233b7..247b815fd81 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx @@ -1,11 +1,24 @@ /* oxlint-disable typescript/no-explicit-any */ import { act, waitFor } from '@testing-library/react' import { updateAppModelConfig } from '@/service/apps' -import { renderHook } from '@/test/console/render' +import { consoleQuery } from '@/service/client' +import { createQueryClientWrapper } from '@/test/console/query-client' +import { renderHook as renderHookWithConsoleState } from '@/test/console/render' +import { createTestQueryClient } from '@/test/query-client' import { AppModeEnum, ModelModeType } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import { useConfiguration } from '../use-configuration' +const renderHook = (callback: () => ReturnType) => { + const queryClient = createTestQueryClient() + return { + ...renderHookWithConsoleState(callback, { + wrapper: createQueryClientWrapper(queryClient), + }), + queryClient, + } +} + const mockSetSettingsDestination = vi.fn() const mockSetShowAppConfigureFeaturesModal = vi.fn() const mockSetDetailSidebarMode = vi.fn() @@ -269,7 +282,18 @@ describe('useConfiguration', () => { }) it('should update model parameters and publish the current configuration', async () => { - const { result } = renderHook(() => useConfiguration()) + const { result, queryClient } = renderHook(() => useConfiguration()) + const detailQueryKey = consoleQuery.apps.byAppId.get.queryKey({ + input: { params: { app_id: 'app-1' } }, + }) + queryClient.setQueryData(detailQueryKey, { + enable_api: false, + enable_site: false, + icon_url: null, + id: 'app-1', + mode: 'chat', + name: 'Cached app', + }) await waitFor(() => { expect(result.current.showLoading).toBe(false) @@ -301,6 +325,7 @@ describe('useConfiguration', () => { url: '/apps/app-1/model-config', }), ) + expect(queryClient.getQueryState(detailQueryKey)?.isInvalidated).toBe(true) }) it('should block publishing when app release permission is missing', async () => { diff --git a/web/app/components/app/configuration/hooks/use-configuration.ts b/web/app/components/app/configuration/hooks/use-configuration.ts index 3420b0bb752..fc0f1d7c7ad 100644 --- a/web/app/components/app/configuration/hooks/use-configuration.ts +++ b/web/app/components/app/configuration/hooks/use-configuration.ts @@ -25,6 +25,7 @@ import type { TextToSpeechConfig, } from '@/models/debug' import type { VisionSettings } from '@/types/app' +import { useMutation } from '@tanstack/react-query' import { useBoolean, useGetState } from 'ahooks' import { clone } from 'es-toolkit/object' import { produce } from 'immer' @@ -67,6 +68,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { PromptMode } from '@/models/debug' import { usePathname } from '@/next/navigation' import { updateAppModelConfig } from '@/service/apps' +import { consoleQuery } from '@/service/client' import { useFileUploadConfig } from '@/service/use-common' import { AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } from '@/types/app' import { getAppACLCapabilities } from '@/utils/permission' @@ -160,6 +162,16 @@ export const useConfiguration = (): ConfigurationViewModel => { const pathname = usePathname() const matched = /\/app\/([^/]+)/.exec(pathname) const appId = matched?.[1] || '' + const { mutateAsync: updateModelConfig } = useMutation({ + mutationFn: (params: Parameters[0]) => + updateAppModelConfig(params), + onSuccess: (_data, _variables, _onMutateResult, context) => + context.client.invalidateQueries({ + queryKey: consoleQuery.apps.byAppId.get.queryKey({ + input: { params: { app_id: appId } }, + }), + }), + }) const [mode, setMode] = useState(AppModeEnum.CHAT) const [publishedConfig, setPublishedConfig] = useState(null) const [conversationId, setConversationId] = useState('') @@ -600,7 +612,7 @@ export const useConfiguration = (): ConfigurationViewModel => { suggestedQuestionsAfterAnswerConfig, t, textToSpeechConfig, - })(updateAppModelConfig, modelAndParameter, features) + })(updateModelConfig, modelAndParameter, features) }, [ appACLCapabilities.canReleaseAndVersion, @@ -630,6 +642,7 @@ export const useConfiguration = (): ConfigurationViewModel => { suggestedQuestionsAfterAnswerConfig, t, textToSpeechConfig, + updateModelConfig, ], ) diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 9eee697e5c0..4ff97e98fcf 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -1,11 +1,10 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' -import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage' import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { AppModeEnum } from '@/types/app' import Apps from '../index' const mockUseExploreAppList = vi.fn() -const mockImportDSL = vi.fn() +const mockImportDSL = vi.hoisted(() => vi.fn()) const mockFetchAppDetail = vi.fn() const mockHandleCheckPluginDependencies = vi.fn() const mockGetRedirection = vi.fn() @@ -13,7 +12,6 @@ const mockPush = vi.fn() const mockToastSuccess = vi.fn() const mockToastError = vi.fn() const mockTrackCreateApp = vi.fn() -const mockInvalidateAppList = vi.hoisted(() => vi.fn()) let latestDebounceFn = () => {} let mockWorkspacePermissionKeys: string[] = ['app.create_and_management'] const mockUserProfile = { id: 'user-1' } @@ -151,12 +149,28 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ vi.mock('@/utils/create-app-tracking', () => ({ trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args), })) -vi.mock('@/service/apps', () => ({ - importDSL: (...args: unknown[]) => mockImportDSL(...args), -})) -vi.mock('@/service/use-apps', () => ({ - useInvalidateAppList: () => mockInvalidateAppList, -})) +vi.mock('@/service/client', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + consoleQuery: { + ...actual.consoleQuery, + systemFeatures: actual.consoleQuery.systemFeatures, + apps: { + ...actual.consoleQuery.apps, + imports: { + ...actual.consoleQuery.apps.imports, + post: { + mutationOptions: () => ({ + mutationFn: ({ body }: { body: Record }) => mockImportDSL(body), + }), + }, + }, + }, + }, + } +}) vi.mock('@/service/explore', () => ({ fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args), })) @@ -227,7 +241,6 @@ describe('Apps', () => { beforeEach(() => { vi.clearAllMocks() - localStorage.clear() mockWorkspacePermissionKeys = ['app.create_and_management'] mockUseExploreAppList.mockReturnValue({ data: defaultData, @@ -337,8 +350,6 @@ describe('Apps', () => { expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated') expect(onSuccess).toHaveBeenCalled() expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('created-app-id') - expect(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY)).toBe('1') - expect(mockInvalidateAppList).toHaveBeenCalledTimes(1) expect(mockGetRedirection).toHaveBeenCalledWith( { id: 'created-app-id', diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index fd7dc7a18f0..67e4d7a97dd 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -3,29 +3,25 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { App } from '@/models/explore' import { cn } from '@langgenius/dify-ui/cn' +import { Input } from '@langgenius/dify-ui/input' import { toast } from '@langgenius/dify-ui/toast' -import { RiRobot2Line } from '@remixicon/react' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { useAtomValue } from 'jotai' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import AppTypeSelector from '@/app/components/app/type-selector' -import { useSetNeedRefreshAppList } from '@/app/components/apps/storage' import Divider from '@/app/components/base/divider' -import Input from '@/app/components/base/input' import Loading from '@/app/components/base/loading' import CreateAppModal from '@/app/components/explore/create-app-modal' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { userProfileIdAtom } from '@/context/account-state' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { DSLImportMode } from '@/models/app' import { useRouter } from '@/next/navigation' -import { importDSL } from '@/service/apps' +import { consoleQuery } from '@/service/client' import { fetchAppDetail } from '@/service/explore' -import { useInvalidateAppList } from '@/service/use-apps' import { useExploreAppList } from '@/service/use-explore' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' @@ -55,11 +51,9 @@ const Apps = ({ onSuccess, onCreateFromBlank }: AppsProps) => { 'app.create_and_management', ) const { push } = useRouter() - const invalidateAppList = useInvalidateAppList() + const { mutateAsync: importApp } = useMutation(consoleQuery.apps.imports.post.mutationOptions()) const allCategoriesEn = AppCategories.RECOMMENDED - const setNeedRefresh = useSetNeedRefreshAppList() - const [keywords, setKeywords] = useState('') const [searchKeywords, setSearchKeywords] = useState('') @@ -141,35 +135,35 @@ const Apps = ({ onSuccess, onCreateFromBlank }: AppsProps) => { }) => { const { export_data, mode } = await fetchAppDetail(currApp?.app.id as string) try { - const app = await importDSL({ - mode: DSLImportMode.YAML_CONTENT, - yaml_content: export_data, - name, - icon_type, - icon, - icon_background, - description, + const app = await importApp({ + body: { + mode: 'yaml-content', + yaml_content: export_data, + name, + icon_type, + icon, + icon_background, + description, + }, }) + if (!app.app_id || !app.app_mode) throw new Error('Completed import is missing app metadata') + trackCreateApp({ source: 'studio_template_list', appMode: mode, templateId: currApp?.app_id }) setIsShowCreateModal(false) toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) if (onSuccess) onSuccess() - if (app.app_id) await handleCheckPluginDependencies(app.app_id) - setNeedRefresh('1') - invalidateAppList() - if (app.app_id) { - getRedirection( - { id: app.app_id, mode: app.app_mode, permission_keys: app.permission_keys }, - push, - { - currentUserId, - resourceMaintainer: currentUserId, - workspacePermissionKeys, - isRbacEnabled, - }, - ) - } + await handleCheckPluginDependencies(app.app_id) + getRedirection( + { id: app.app_id, mode: app.app_mode, permission_keys: app.permission_keys }, + push, + { + currentUserId, + resourceMaintainer: currentUserId, + workspacePermissionKeys, + isRbacEnabled, + }, + ) } catch { toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) } @@ -196,17 +190,27 @@ const Apps = ({ onSuccess, onCreateFromBlank }: AppsProps) => {
- $['newAppFromTemplate.searchAllTemplate'], { ns: 'app' }) as string - } - value={keywords} - onChange={(e) => handleKeywordsChange(e.target.value)} - onClear={() => handleKeywordsChange('')} - /> +
+ $['newAppFromTemplate.searchAllTemplate'], { ns: 'app' })} + value={keywords} + onChange={(e) => handleKeywordsChange(e.target.value)} + /> + {keywords && ( + + )} +
@@ -294,7 +298,7 @@ function NoTemplateFound() { return (
- +

{t(($) => $['newApp.noTemplateFound'], { ns: 'app' })} diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index 2607994156d..0c3a276c8f1 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -1,10 +1,8 @@ import type { App } from '@/types/app' import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage' import { useProviderContext } from '@/context/provider-context' import { useRouter } from '@/next/navigation' -import { createApp } from '@/service/apps' import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' @@ -14,12 +12,12 @@ import CreateAppModal from '../index' const ahooksMocks = vi.hoisted(() => ({ keyPressHandlers: [] as Array<() => void>, })) -const mockInvalidateAppList = vi.hoisted(() => vi.fn()) const mockConsoleState = vi.hoisted(() => ({ userProfile: { id: 'user-1' }, workspacePermissionKeys: ['app.create_and_management'] as string[], })) const mockConsoleStateReader = vi.hoisted(() => vi.fn()) +const mockCreateApp = vi.hoisted(() => vi.fn()) vi.mock('ahooks', () => ({ useDebounceFn: unknown>(fn: T) => { @@ -43,12 +41,25 @@ vi.mock('@/next/navigation', () => ({ vi.mock('@/utils/create-app-tracking', () => ({ trackCreateApp: vi.fn(), })) -vi.mock('@/service/apps', () => ({ - createApp: vi.fn(), -})) -vi.mock('@/service/use-apps', () => ({ - useInvalidateAppList: () => mockInvalidateAppList, -})) +vi.mock('@/service/client', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + consoleQuery: { + ...actual.consoleQuery, + systemFeatures: actual.consoleQuery.systemFeatures, + apps: { + ...actual.consoleQuery.apps, + post: { + mutationOptions: () => ({ + mutationFn: ({ body }: { body: Record }) => mockCreateApp(body), + }), + }, + }, + }, + } +}) const toastMocks = vi.hoisted(() => ({ mockToastSuccess: vi.fn(), mockToastError: vi.fn(), @@ -93,7 +104,6 @@ vi.mock('@/hooks/use-theme', () => ({ const mockUseRouter = vi.mocked(useRouter) const mockPush = vi.fn() -const mockCreateApp = vi.mocked(createApp) const mockTrackCreateApp = vi.mocked(trackCreateApp) const mockGetRedirection = vi.mocked(getRedirection) const mockUseProviderContext = vi.mocked(useProviderContext) @@ -126,8 +136,6 @@ const renderModal = () => { } describe('CreateAppModal', () => { - const mockSetItem = vi.fn() - beforeEach(() => { vi.clearAllMocks() ahooksMocks.keyPressHandlers.length = 0 @@ -147,18 +155,6 @@ describe('CreateAppModal', () => { }) mockConsoleState.userProfile = { id: 'user-1' } mockConsoleState.workspacePermissionKeys = ['app.create_and_management'] - mockSetItem.mockClear() - Object.defineProperty(window, 'localStorage', { - value: { - setItem: mockSetItem, - getItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), - key: vi.fn(), - length: 0, - }, - writable: true, - }) }) it('creates an app, notifies success, and fires callbacks', async () => { @@ -192,8 +188,6 @@ describe('CreateAppModal', () => { expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated') expect(onSuccess).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() - await waitFor(() => expect(mockSetItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY, '1')) - expect(mockInvalidateAppList).toHaveBeenCalledTimes(1) await waitFor(() => expect(mockGetRedirection).toHaveBeenCalledWith(mockApp, mockPush, { currentUserId: 'user-1', @@ -230,6 +224,11 @@ describe('CreateAppModal', () => { appMode: AppModeEnum.ADVANCED_CHAT, }) }) + const createButton = screen.getByRole('button', { name: /app\.newApp\.Create/ }) + expect(createButton).toHaveAttribute('aria-disabled', 'true') + fireEvent.click(createButton) + + expect(mockCreateApp).toHaveBeenCalledTimes(1) expect(mockGetRedirection).not.toHaveBeenCalled() resolveTracking?.() @@ -392,6 +391,11 @@ describe('CreateAppModal', () => { const createButton = screen.getByRole('button', { name: /app\.newApp\.Create/ }) fireEvent.click(createButton) + await waitFor(() => { + expect(mockCreateApp).toHaveBeenCalledTimes(1) + }) + + expect(createButton).toHaveAttribute('aria-disabled', 'true') fireEvent.click(createButton) expect(mockCreateApp).toHaveBeenCalledTimes(1) diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index f776e509eb2..41fa7f4c235 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -2,28 +2,21 @@ import type { Hotkey } from '@tanstack/react-hotkeys' import type { AppIconSelection } from '../../base/app-icon-picker' +import { zPostAppsBody } from '@dify/contracts/api/console/apps/zod.gen' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' -import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react' import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { useAtomValue } from 'jotai' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useSetNeedRefreshAppList } from '@/app/components/apps/storage' import AppIcon from '@/app/components/base/app-icon' import Divider from '@/app/components/base/divider' -import { - BubbleTextMod, - ChatBot, - ListSparkle, - Logic, -} from '@/app/components/base/icons/src/vender/solid/communication' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { userProfileIdAtom } from '@/context/account-state' import { workspacePermissionKeysAtom } from '@/context/permission-state' @@ -31,8 +24,7 @@ import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useTheme from '@/hooks/use-theme' import { useRouter } from '@/next/navigation' -import { createApp } from '@/service/apps' -import { useInvalidateAppList } from '@/service/use-apps' +import { consoleQuery } from '@/service/client' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { trackCreateApp } from '@/utils/create-app-tracking' @@ -82,11 +74,9 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') - const invalidateAppList = useInvalidateAppList() - - const isCreatingRef = useRef(false) - - const setNeedRefresh = useSetNeedRefreshAppList() + const { mutateAsync: createApp } = useMutation(consoleQuery.apps.post.mutationOptions()) + const creatingRef = useRef(false) + const [isCreating, setIsCreating] = useState(false) const onCreate = useCallback(async () => { if (!canCreateApp) return @@ -95,24 +85,32 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: toast.error(t(($) => $['newApp.appTypeRequired'], { ns: 'app' })) return } + const appModeResult = zPostAppsBody.shape.mode.safeParse(appMode) + if (!appModeResult.success) { + toast.error(t(($) => $['newApp.appTypeRequired'], { ns: 'app' })) + return + } if (!name.trim()) { toast.error(t(($) => $['newApp.nameNotEmpty'], { ns: 'app' })) return } - if (isCreatingRef.current) return - isCreatingRef.current = true + if (creatingRef.current) return + creatingRef.current = true + setIsCreating(true) try { const app = await createApp({ - name, - description, - icon_type: appIcon.type, - icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, - icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, - mode: appMode, + body: { + name, + description, + icon_type: appIcon.type, + icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, + icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, + mode: appModeResult.data, + }, }) try { - await trackCreateApp({ source: 'studio_blank', appMode: app.mode }) + await trackCreateApp({ source: 'studio_blank', appMode }) } catch { // Analytics should not turn a successful app creation into a failed flow. } @@ -120,8 +118,6 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) onSuccess() onClose() - setNeedRefresh('1') - invalidateAppList() getRedirection(app, push, { currentUserId, resourceMaintainer: app.maintainer, @@ -134,8 +130,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: ? error.message : t(($) => $['newApp.appCreateFailed'], { ns: 'app' }), ) + } finally { + creatingRef.current = false + setIsCreating(false) } - isCreatingRef.current = false }, [ canCreateApp, currentUserId, @@ -149,8 +147,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: push, workspacePermissionKeys, isRbacEnabled, - setNeedRefresh, - invalidateAppList, + createApp, ]) const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) @@ -189,7 +186,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: description={t(($) => $['newApp.workflowShortDescription'], { ns: 'app' })} icon={

- +
} onClick={() => { @@ -202,7 +202,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: description={t(($) => $['newApp.advancedShortDescription'], { ns: 'app' })} icon={
- +
} onClick={() => { @@ -221,9 +224,9 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: {t(($) => $['newApp.forBeginners'], { ns: 'app' })} -
@@ -235,7 +238,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: description={t(($) => $['newApp.chatbotShortDescription'], { ns: 'app' })} icon={
- +
} onClick={() => { @@ -248,7 +254,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: description={t(($) => $['newApp.agentShortDescription'], { ns: 'app' })} icon={
- +
} onClick={() => { @@ -261,7 +270,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: description={t(($) => $['newApp.completionShortDescription'], { ns: 'app' })} icon={
- +
} onClick={() => { @@ -338,14 +350,14 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: > {t(($) => $['newApp.noIdeaTip'], { ns: 'app' })}
-
- +
{t(($) => $.switch, { ns: 'app' })} @@ -188,7 +193,10 @@ const SwitchAppModal = ({ { + setRemoveOriginal(checked) + if (checked) setShowConfirmDelete(true) + }} /> {t(($) => $.removeOriginal, { ns: 'app' })} diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 94d7548960d..69d6ee023b0 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -1,10 +1,8 @@ -import type { Mock } from 'vitest' -import type { App } from '@/types/app' +import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen' import { fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' import { AccessMode } from '@/models/access-control' -import * as appsService from '@/service/apps' import * as exploreService from '@/service/explore' import { renderWithConsoleQuery } from '@/test/console/query-data' import { AppModeEnum } from '@/types/app' @@ -26,6 +24,79 @@ const mockWorkflowAppDslExport = vi.hoisted(() => ({ exportWorkflowAppDsl: vi.fn(), isExporting: false, })) +const mockCopyApp = vi.hoisted(() => + vi.fn( + (_variables: unknown): Promise => + Promise.resolve({ + id: 'new-app-id', + mode: 'chat', + maintainer: 'user-1', + permission_keys: [], + }), + ), +) +const mockUpdateAppMutation = vi.hoisted(() => + vi.fn((_variables: unknown): Promise => Promise.resolve()), +) +const mockDeleteAppMutation = vi.hoisted(() => + vi.fn((_variables: unknown): Promise => Promise.resolve()), +) +const mockStarAppMutation = vi.hoisted(() => + vi.fn((_variables: unknown): Promise => Promise.resolve()), +) +const mockUnstarAppMutation = vi.hoisted(() => + vi.fn((_variables: unknown): Promise => Promise.resolve()), +) + +vi.mock('@/service/client', async (importOriginal) => { + const actual = await importOriginal() + const withMutation = (operation: object, mutationFn: typeof mockCopyApp) => + new Proxy(operation, { + get(target, property, receiver) { + if (property === 'mutationOptions') + return () => ({ mutationFn: (variables: unknown) => mutationFn(variables) }) + return Reflect.get(target, property, receiver) + }, + }) + const copy = new Proxy(actual.consoleQuery.apps.byAppId.copy, { + get(target, property, receiver) { + if (property === 'post') return withMutation(target.post, mockCopyApp) + return Reflect.get(target, property, receiver) + }, + }) + const star = new Proxy(actual.consoleQuery.apps.byAppId.star, { + get(target, property, receiver) { + if (property === 'post') return withMutation(target.post, mockStarAppMutation) + if (property === 'delete') return withMutation(target.delete, mockUnstarAppMutation) + return Reflect.get(target, property, receiver) + }, + }) + const byAppId = new Proxy(actual.consoleQuery.apps.byAppId, { + get(target, property, receiver) { + if (property === 'copy') return copy + if (property === 'put') return withMutation(target.put, mockUpdateAppMutation) + if (property === 'delete') return withMutation(target.delete, mockDeleteAppMutation) + if (property === 'star') return star + return Reflect.get(target, property, receiver) + }, + }) + const apps = new Proxy(actual.consoleQuery.apps, { + get(target, property, receiver) { + if (property === 'byAppId') return byAppId + return Reflect.get(target, property, receiver) + }, + }) + + return { + ...actual, + consoleQuery: new Proxy(actual.consoleQuery, { + get(target, property, receiver) { + if (property === 'apps') return apps + return Reflect.get(target, property, receiver) + }, + }), + } +}) vi.mock('@/app/components/app/use-export-app-dsl', () => ({ useExportAppDsl: () => mockAppDslExport, @@ -129,23 +200,6 @@ vi.mock('@/context/provider-context', () => ({ vi.mock('@/service/apps', () => ({ deleteApp: vi.fn(() => Promise.resolve()), - updateAppInfo: vi.fn(() => Promise.resolve()), - copyApp: vi.fn(() => Promise.resolve({ id: 'new-app-id' })), -})) - -const mockDeleteAppMutation = vi.fn(() => Promise.resolve()) -const mockToggleAppStarMutation = vi.fn(() => Promise.resolve()) -let mockDeleteMutationPending = false -let mockToggleStarMutationPending = false -vi.mock('@/service/use-apps', () => ({ - useDeleteAppMutation: () => ({ - mutateAsync: mockDeleteAppMutation, - isPending: mockDeleteMutationPending, - }), - useToggleAppStarMutation: () => ({ - mutateAsync: mockToggleAppStarMutation, - isPending: mockToggleStarMutationPending, - }), })) vi.mock('@/service/explore', () => ({ @@ -281,11 +335,9 @@ vi.mock('@/next/dynamic', () => ({ return function MockSwitchAppModal({ show, onClose, - onSuccess, }: { show: boolean onClose: () => void - onSuccess: () => void }) { if (!show) return null return React.createElement( @@ -296,11 +348,6 @@ vi.mock('@/next/dynamic', () => ({ { onClick: onClose, 'data-testid': 'close-switch-modal' }, 'Close', ), - React.createElement( - 'button', - { onClick: onSuccess, 'data-testid': 'confirm-switch-modal' }, - 'Switch', - ), ) } } @@ -388,37 +435,31 @@ vi.mock('@/app/components/app/type-selector', () => ({ AppTypeIcon: () => React.createElement('div', { 'data-testid': 'app-type-icon' }), })) -const createMockApp = (overrides: Partial = {}): App => - ({ - id: 'test-app-id', - name: 'Test App', - description: 'Test app description', - mode: AppModeEnum.CHAT, - icon: '🤖', - icon_type: 'emoji' as const, - icon_background: '#FFEAD5', - icon_url: null, - author_name: 'Test Author', - created_by: 'user-1', - maintainer: 'user-1', - created_at: 1704067200, - updated_at: 1704153600, - tags: [], - use_icon_as_answer_icon: false, - max_active_requests: null, - access_mode: AccessMode.PUBLIC, - has_draft_trigger: false, - enable_site: true, - enable_api: true, - api_rpm: 60, - api_rph: 3600, - is_demo: false, - ...overrides, - }) as App +const createMockApp = (overrides: Partial = {}): AppPartial => ({ + id: 'test-app-id', + name: 'Test App', + description: 'Test app description', + mode: AppModeEnum.CHAT, + icon: '🤖', + icon_type: 'emoji' as const, + icon_background: '#FFEAD5', + icon_url: null, + author_name: 'Test Author', + created_by: 'user-1', + maintainer: 'user-1', + created_at: 1704067200, + updated_at: 1704153600, + tags: [], + use_icon_as_answer_icon: false, + max_active_requests: null, + access_mode: AccessMode.PUBLIC, + has_draft_trigger: false, + permission_keys: [], + ...overrides, +}) describe('AppCard', () => { const mockApp = createMockApp() - const mockOnRefresh = vi.fn() beforeEach(() => { vi.clearAllMocks() @@ -427,8 +468,12 @@ describe('AppCard', () => { mockRbacEnabled = true mockUserCanAccessApp.result = true mockUserCanAccessApp.isLoading = false - mockDeleteMutationPending = false - mockToggleStarMutationPending = false + mockCopyApp.mockResolvedValue({ + id: 'new-app-id', + mode: 'chat', + maintainer: 'user-1', + permission_keys: [], + }) mockAppDslExport.isExporting = false mockAppDslExport.exportAppDsl.mockResolvedValue({ status: 'downloaded' }) mockWorkflowAppDslExport.isExporting = false @@ -446,9 +491,7 @@ describe('AppCard', () => { author_name: 'Readonly Author', created_by: 'another-user', maintainer: 'another-user', - tags: [ - { id: 'tag-preview', name: 'Readonly Tag', type: 'app' as const, binding_count: '' }, - ], + tags: [{ id: 'tag-preview', name: 'Readonly Tag', type: 'app' as const }], permission_keys: [AppACLPermission.Preview], }) @@ -568,10 +611,10 @@ describe('AppCard', () => { it('should display refreshed tag names from app props when tag ids stay the same', () => { const firstApp = createMockApp({ - tags: [{ id: 'tag1', name: 'Old Tag', type: 'app' as const, binding_count: '' }], + tags: [{ id: 'tag1', name: 'Old Tag', type: 'app' as const }], }) const refreshedApp = createMockApp({ - tags: [{ id: 'tag1', name: 'New Tag', type: 'app' as const, binding_count: '' }], + tags: [{ id: 'tag1', name: 'New Tag', type: 'app' as const }], }) const { rerender } = render() @@ -589,7 +632,7 @@ describe('AppCard', () => { mockConsoleState.userProfile = { id: 'user-2' } const editableApp = createMockApp({ maintainer: 'user-1', - tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: '' }], + tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const }], permission_keys: [AppACLPermission.Edit], }) @@ -607,7 +650,7 @@ describe('AppCard', () => { mockConsoleState.userProfile = { id: 'user-2' } const tagManageApp = createMockApp({ maintainer: 'user-1', - tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: '' }], + tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const }], permission_keys: [AppACLPermission.ViewLayout], }) @@ -625,7 +668,7 @@ describe('AppCard', () => { mockConsoleState.userProfile = { id: 'user-2' } const readonlyApp = createMockApp({ maintainer: 'user-1', - tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: '' }], + tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const }], permission_keys: [AppACLPermission.ViewLayout], }) @@ -636,11 +679,6 @@ describe('AppCard', () => { 'false', ) }) - - it('should render with onRefresh callback', () => { - render() - expect(screen.getByRole('link', { name: 'Test App' })).toBeInTheDocument() - }) }) describe('Access Mode Icons', () => { @@ -694,33 +732,29 @@ describe('AppCard', () => { }) it('should star the app from the card action without navigating', async () => { - render() + render() fireEvent.click(screen.getByRole('button', { name: 'app.studio.starApp' })) await waitFor(() => { - expect(mockToggleAppStarMutation).toHaveBeenCalledWith({ - appId: mockApp.id, - isStarred: false, + expect(mockStarAppMutation).toHaveBeenCalledWith({ + params: { app_id: mockApp.id }, }) }) - expect(mockOnRefresh).toHaveBeenCalledTimes(1) expect(mockPush).not.toHaveBeenCalled() }) it('should unstar the app from the filled star action', async () => { const starredApp = createMockApp({ is_starred: true }) - render() + render() fireEvent.click(screen.getByRole('button', { name: 'app.studio.unstarApp' })) await waitFor(() => { - expect(mockToggleAppStarMutation).toHaveBeenCalledWith({ - appId: starredApp.id, - isStarred: true, + expect(mockUnstarAppMutation).toHaveBeenCalledWith({ + params: { app_id: starredApp.id }, }) }) - expect(mockOnRefresh).toHaveBeenCalledTimes(1) }) }) @@ -822,7 +856,7 @@ describe('AppCard', () => { }) }) - it('should show switch option when user can edit app without app creation permission', async () => { + it('should hide duplicate but keep app-authorized switch without app creation permission', async () => { mockConsoleState.workspacePermissionKeys = [] const editableChatApp = createMockApp({ created_by: 'another-user', @@ -992,7 +1026,7 @@ describe('AppCard', () => { describe('API Callbacks', () => { it('should call deleteApp API when confirming delete', async () => { - render() + render() // Open dropdown menu and click delete fireEvent.click(getOperationsTrigger()) @@ -1010,29 +1044,10 @@ describe('AppCard', () => { }) }) - it('should not call onRefresh after successful delete', async () => { - render() - - fireEvent.click(getOperationsTrigger()) - fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) - expect(await screen.findByRole('alertdialog')).toBeInTheDocument() - - // Fill in the confirmation input with app name - const deleteInput = screen.getByRole('textbox') - fireEvent.change(deleteInput, { target: { value: mockApp.name } }) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - - await waitFor(() => { - expect(mockDeleteAppMutation).toHaveBeenCalled() - }) - expect(mockOnRefresh).not.toHaveBeenCalled() - }) - it('should handle delete failure', async () => { - ;(mockDeleteAppMutation as Mock).mockRejectedValueOnce(new Error('Delete failed')) + mockDeleteAppMutation.mockRejectedValueOnce(new Error('Delete failed')) - render() + render() fireEvent.click(getOperationsTrigger()) fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) @@ -1054,7 +1069,7 @@ describe('AppCard', () => { }) it('should handle delete failure without an error message', async () => { - ;(mockDeleteAppMutation as Mock).mockRejectedValueOnce({}) + mockDeleteAppMutation.mockRejectedValueOnce({}) render() @@ -1074,26 +1089,7 @@ describe('AppCard', () => { }) }) - it('should call updateAppInfo API when editing app', async () => { - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - fireEvent.click(screen.getByText('app.editApp')) - }) - - await waitFor(() => { - expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('confirm-edit-modal')) - - await waitFor(() => { - expect(appsService.updateAppInfo).toHaveBeenCalled() - }) - }) - - it('should edit successfully without onRefresh callback', async () => { + it('should update the app and close the edit modal', async () => { render() fireEvent.click(getOperationsTrigger()) @@ -1108,50 +1104,23 @@ describe('AppCard', () => { fireEvent.click(screen.getByTestId('confirm-edit-modal')) await waitFor(() => { - expect(appsService.updateAppInfo).toHaveBeenCalled() + expect(mockUpdateAppMutation).toHaveBeenCalledWith({ + params: { app_id: mockApp.id }, + body: { + name: 'Updated App', + icon_type: 'emoji', + icon: '🎯', + icon_background: '#FFEAD5', + description: 'Updated description', + use_icon_as_answer_icon: false, + max_active_requests: null, + }, + }) expect(screen.queryByTestId('edit-app-modal')).not.toBeInTheDocument() }) }) it('should call copyApp API when duplicating app', async () => { - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - fireEvent.click(screen.getByText('app.duplicate')) - }) - - await waitFor(() => { - expect(screen.getByTestId('duplicate-modal')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('confirm-duplicate-modal')) - - await waitFor(() => { - expect(appsService.copyApp).toHaveBeenCalled() - }) - }) - - it('should call onPlanInfoChanged after successful duplication', async () => { - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - fireEvent.click(screen.getByText('app.duplicate')) - }) - - await waitFor(() => { - expect(screen.getByTestId('duplicate-modal')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('confirm-duplicate-modal')) - - await waitFor(() => { - expect(mockOnPlanInfoChanged).toHaveBeenCalled() - }) - }) - - it('should duplicate successfully without onRefresh callback', async () => { render() fireEvent.click(getOperationsTrigger()) @@ -1166,16 +1135,12 @@ describe('AppCard', () => { fireEvent.click(screen.getByTestId('confirm-duplicate-modal')) await waitFor(() => { - expect(appsService.copyApp).toHaveBeenCalled() - expect(mockOnPlanInfoChanged).toHaveBeenCalled() - expect(screen.queryByTestId('duplicate-modal')).not.toBeInTheDocument() + expect(mockCopyApp).toHaveBeenCalled() }) }) - it('should handle copy failure', async () => { - ;(appsService.copyApp as Mock).mockRejectedValueOnce(new Error('Copy failed')) - - render() + it('should call onPlanInfoChanged after successful duplication', async () => { + render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { @@ -1189,7 +1154,28 @@ describe('AppCard', () => { fireEvent.click(screen.getByTestId('confirm-duplicate-modal')) await waitFor(() => { - expect(appsService.copyApp).toHaveBeenCalled() + expect(mockOnPlanInfoChanged).toHaveBeenCalled() + }) + }) + + it('should handle copy failure', async () => { + mockCopyApp.mockRejectedValueOnce(new Error('Copy failed')) + + render() + + fireEvent.click(getOperationsTrigger()) + await waitFor(() => { + fireEvent.click(screen.getByText('app.duplicate')) + }) + + await waitFor(() => { + expect(screen.getByTestId('duplicate-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-duplicate-modal')) + + await waitFor(() => { + expect(mockCopyApp).toHaveBeenCalled() expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed', @@ -1255,46 +1241,6 @@ describe('AppCard', () => { }) }) - it('should call onRefresh after successful switch', async () => { - const chatApp = { ...mockApp, mode: AppModeEnum.CHAT } - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - fireEvent.click(screen.getByText('app.switch')) - }) - - await waitFor(() => { - expect(screen.getByTestId('switch-modal')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('confirm-switch-modal')) - - await waitFor(() => { - expect(mockOnRefresh).toHaveBeenCalled() - }) - }) - - it('should close switch modal after success without onRefresh callback', async () => { - const chatApp = { ...mockApp, mode: AppModeEnum.CHAT } - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - fireEvent.click(screen.getByText('app.switch')) - }) - - await waitFor(() => { - expect(screen.getByTestId('switch-modal')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('confirm-switch-modal')) - - await waitFor(() => { - expect(screen.queryByTestId('switch-modal')).not.toBeInTheDocument() - }) - }) - it('should open switch modal for completion mode apps', async () => { const completionApp = { ...mockApp, mode: AppModeEnum.COMPLETION } render() @@ -1423,101 +1369,9 @@ describe('AppCard', () => { }) }) - describe('Edge Cases', () => { - it('should handle empty description', () => { - const appNoDesc = { ...mockApp, description: '' } - render() - expect(screen.getByText('Test App')).toBeInTheDocument() - }) - - it('should handle long app name', () => { - const longNameApp = { - ...mockApp, - name: 'This is a very long app name that might overflow the container', - } - render() - expect(screen.getByText(longNameApp.name)).toBeInTheDocument() - }) - - it('should handle empty tags array', () => { - const noTagsApp = { ...mockApp, tags: [] } - // With empty tags, the component should still render successfully - render() - expect(screen.getByRole('link', { name: 'Test App' })).toBeInTheDocument() - }) - - it('should handle missing author name', () => { - const noAuthorApp = { ...mockApp, author_name: '' } - render() - expect(screen.getByRole('link', { name: 'Test App' })).toBeInTheDocument() - }) - - it('should handle null icon_url', () => { - const nullIconApp = { ...mockApp, icon_url: null } - // With null icon_url, the component should fall back to emoji icon and render successfully - render() - expect(screen.getByRole('link', { name: 'Test App' })).toBeInTheDocument() - }) - - it('should use created_at when updated_at is not available', () => { - const noUpdateApp = { ...mockApp, updated_at: 0 } - render() - expect(screen.getByText(/edited/i)).toBeInTheDocument() - }) - - it('should handle agent chat mode apps', () => { - const agentApp = { ...mockApp, mode: AppModeEnum.AGENT_CHAT } - render() - expect(screen.getByRole('link', { name: 'Test App' })).toBeInTheDocument() - }) - - it('should handle advanced chat mode apps', () => { - const advancedApp = { ...mockApp, mode: AppModeEnum.ADVANCED_CHAT } - render() - expect(screen.getByRole('link', { name: 'Test App' })).toBeInTheDocument() - }) - - it('should handle apps with multiple tags', () => { - const multiTagApp = { - ...mockApp, - tags: [ - { id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: '' }, - { id: 'tag2', name: 'Tag 2', type: 'app' as const, binding_count: '' }, - { id: 'tag3', name: 'Tag 3', type: 'app' as const, binding_count: '' }, - ], - } - render() - // Verify the tag selector renders (actual tag display is handled by the real TagSelector component) - expect(screen.getByLabelText('tag-selector')).toBeInTheDocument() - }) - + describe('Edit mutation', () => { it('should handle edit failure', async () => { - ;(appsService.updateAppInfo as Mock).mockRejectedValueOnce(new Error('Edit failed')) - - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - fireEvent.click(screen.getByText('app.editApp')) - }) - - await waitFor(() => { - expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('confirm-edit-modal')) - - await waitFor(() => { - expect(appsService.updateAppInfo).toHaveBeenCalled() - expect(toastMocks.record).toHaveBeenCalledWith({ - type: 'error', - message: expect.stringContaining('Edit failed'), - }) - }) - }) - - it('should fall back to the default edit failure message', async () => { - ;(appsService.updateAppInfo as Mock).mockRejectedValueOnce({ message: '' }) + mockUpdateAppMutation.mockRejectedValueOnce(new Error('Edit failed')) render() @@ -1533,13 +1387,18 @@ describe('AppCard', () => { fireEvent.click(screen.getByTestId('confirm-edit-modal')) await waitFor(() => { - expect(appsService.updateAppInfo).toHaveBeenCalled() - expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.editFailed' }) + expect(mockUpdateAppMutation).toHaveBeenCalled() + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: expect.stringContaining('Edit failed'), + }) }) }) - it('should close edit modal after successful edit', async () => { - render() + it('should fall back to the default edit failure message', async () => { + mockUpdateAppMutation.mockRejectedValueOnce({ message: '' }) + + render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { @@ -1553,90 +1412,13 @@ describe('AppCard', () => { fireEvent.click(screen.getByTestId('confirm-edit-modal')) await waitFor(() => { - expect(mockOnRefresh).toHaveBeenCalled() - }) - }) - - it('should render all app modes correctly', () => { - const modes = [ - AppModeEnum.CHAT, - AppModeEnum.COMPLETION, - AppModeEnum.WORKFLOW, - AppModeEnum.ADVANCED_CHAT, - AppModeEnum.AGENT_CHAT, - ] - - modes.forEach((mode) => { - const testApp = { ...mockApp, mode } - const { unmount } = render() - expect(screen.getByRole('link', { name: 'Test App' })).toBeInTheDocument() - unmount() + expect(mockUpdateAppMutation).toHaveBeenCalled() + expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.editFailed' }) }) }) }) - // -------------------------------------------------------------------------- - // Additional Edge Cases for Coverage - // -------------------------------------------------------------------------- - describe('Additional Coverage', () => { - it('should handle onRefresh callback in switch modal success', async () => { - const chatApp = createMockApp({ mode: AppModeEnum.CHAT }) - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - fireEvent.click(screen.getByText('app.switch')) - }) - - await waitFor(() => { - expect(screen.getByTestId('switch-modal')).toBeInTheDocument() - }) - - // Trigger success callback - fireEvent.click(screen.getByTestId('confirm-switch-modal')) - - await waitFor(() => { - expect(mockOnRefresh).toHaveBeenCalled() - }) - }) - - it('should render dropdown menu with correct styling for different app modes', async () => { - // Test completion mode styling - const completionApp = createMockApp({ mode: AppModeEnum.COMPLETION }) - const { unmount } = render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - expect(screen.getByText('app.editApp')).toBeInTheDocument() - }) - - unmount() - - // Test workflow mode styling - const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW }) - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - expect(screen.getByText('app.editApp')).toBeInTheDocument() - }) - }) - - it('should stop propagation when clicking tag selector area', () => { - const multiTagApp = createMockApp({ - tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: '' }], - }) - - render() - - const tagSelector = screen.getByLabelText('tag-selector') - expect(tagSelector).toBeInTheDocument() - - // Click on tag selector wrapper to trigger stopPropagation - const tagSelectorWrapper = tagSelector.closest('div') - if (tagSelectorWrapper) fireEvent.click(tagSelectorWrapper) - }) - + describe('Operations behavior', () => { it('should close operations menu after selecting an item', async () => { render() @@ -1649,24 +1431,6 @@ describe('AppCard', () => { }) }) - it('should click open in explore button', async () => { - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - const openInExploreBtn = screen.getByText('app.openInExplore') - fireEvent.click(openInExploreBtn) - }) - - // Verify openAsyncWindow was called with callback and options - await waitFor(() => { - expect(mockOpenAsyncWindow).toHaveBeenCalledWith( - expect.any(Function), - expect.objectContaining({ onError: expect.any(Function) }), - ) - }) - }) - it('should handle open in explore via async window', async () => { let openedUrl = '' // Configure mockOpenAsyncWindow to actually call the callback @@ -1688,33 +1452,6 @@ describe('AppCard', () => { }) }) - it('should handle open in explore API failure', async () => { - ;(exploreService.fetchInstalledAppList as Mock).mockRejectedValueOnce(new Error('API Error')) - - // Configure mockOpenAsyncWindow to call the callback and trigger error - mockOpenAsyncWindow.mockImplementationOnce( - async (callback: () => Promise, options?: { onError?: (err: unknown) => void }) => { - try { - await callback() - } catch (err) { - options?.onError?.(err) - } - }, - ) - - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - const openInExploreBtn = screen.getByText('app.openInExplore') - fireEvent.click(openInExploreBtn) - }) - - await waitFor(() => { - expect(exploreService.fetchInstalledAppList).toHaveBeenCalled() - }) - }) - it('should show string errors from open in explore onError callback', async () => { mockOpenAsyncWindow.mockImplementationOnce( async ( @@ -1739,18 +1476,6 @@ describe('AppCard', () => { }) describe('Access Control', () => { - it('should render operations menu correctly', async () => { - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - expect(screen.getByText('app.editApp')).toBeInTheDocument() - expect(screen.getByText('app.duplicate')).toBeInTheDocument() - expect(screen.getByText('app.export')).toBeInTheDocument() - expect(screen.getByText('common.operation.delete')).toBeInTheDocument() - }) - }) - it('should render the tour-controlled operations menu as presentation only', async () => { render( { }) it('should handle case when installed_apps is empty array', async () => { - ;(exploreService.fetchInstalledAppList as Mock).mockResolvedValueOnce({ installed_apps: [] }) + vi.mocked(exploreService.fetchInstalledAppList).mockResolvedValueOnce({ + has_more: false, + installed_apps: [], + next_cursor: null, + }) // Configure mockOpenAsyncWindow to call the callback and trigger error mockOpenAsyncWindow.mockImplementationOnce( @@ -1834,7 +1563,7 @@ describe('AppCard', () => { }) it('should handle case when API throws in callback', async () => { - ;(exploreService.fetchInstalledAppList as Mock).mockRejectedValueOnce( + vi.mocked(exploreService.fetchInstalledAppList).mockRejectedValueOnce( new Error('Network error'), ) @@ -1972,27 +1701,7 @@ describe('AppCard', () => { }) }) - it('should close access control modal and call onRefresh', async () => { - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - fireEvent.click(screen.getByText('app.accessControl')) - }) - - await waitFor(() => { - expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() - }) - - // Confirm access control - fireEvent.click(screen.getByTestId('confirm-access-control')) - - await waitFor(() => { - expect(mockOnRefresh).toHaveBeenCalled() - }) - }) - - it('should close access control modal after confirm without onRefresh callback', async () => { + it('should close access control modal after confirmation', async () => { render() fireEvent.click(getOperationsTrigger()) diff --git a/web/app/components/apps/__tests__/app-list-infinite-scroll-sentinel.spec.tsx b/web/app/components/apps/__tests__/app-list-infinite-scroll-sentinel.spec.tsx new file mode 100644 index 00000000000..b349bb8f590 --- /dev/null +++ b/web/app/components/apps/__tests__/app-list-infinite-scroll-sentinel.spec.tsx @@ -0,0 +1,104 @@ +import type { RefObject } from 'react' +import { act, render } from '@testing-library/react' +import { useRef } from 'react' +import { AppListInfiniteScrollSentinel } from '../app-list-infinite-scroll-sentinel' + +type MockObserver = { + callback: IntersectionObserverCallback + options?: IntersectionObserverInit +} + +const observers: MockObserver[] = [] + +function getObserver(index: number) { + const observer = observers[index] + if (!observer) throw new Error(`Missing observer at index ${index}`) + return observer +} + +function Harness({ + canLoadMore, + fetchNextPage, +}: { + canLoadMore: boolean + fetchNextPage: () => Promise +}) { + const scrollViewportRef = useRef(null) + + return ( +
+ } + /> +
+ ) +} + +describe('AppListInfiniteScrollSentinel', () => { + beforeEach(() => { + observers.length = 0 + vi.stubGlobal( + 'IntersectionObserver', + class MockIntersectionObserver { + callback: IntersectionObserverCallback + disconnect() {} + observe() {} + root = null + rootMargin = '' + thresholds = [] + takeRecords = () => [] + unobserve = vi.fn() + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.callback = callback + observers.push({ + callback, + options, + }) + } + }, + ) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('loads again after the busy state clears', () => { + const fetchNextPage = vi.fn().mockResolvedValue(undefined) + const { container, rerender } = render() + + const firstObserver = getObserver(0) + const scrollRoot = container.firstElementChild + expect(firstObserver.options?.root).toBe(scrollRoot) + + act(() => { + firstObserver.callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + expect(fetchNextPage).toHaveBeenCalledTimes(1) + + rerender() + act(() => { + firstObserver.callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + expect(fetchNextPage).toHaveBeenCalledTimes(1) + + rerender() + const secondObserver = getObserver(1) + act(() => { + secondObserver.callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + expect(fetchNextPage).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/app/components/apps/__tests__/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx index c4bd3fc3d23..b87a6e23e8e 100644 --- a/web/app/components/apps/__tests__/index.spec.tsx +++ b/web/app/components/apps/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ import AppListContext from '@/context/app-list-context' import { fetchAppDetail } from '@/service/explore' import { render } from '@/test/console/render' import { AppModeEnum } from '@/types/app' -import Apps from '../index' +import { Apps } from '../index' vi.mock('@/next/dynamic', () => ({ default: (loader: () => Promise<{ default: React.ComponentType }>) => { @@ -140,7 +140,7 @@ vi.mock('../list', () => { ) } - return { default: MockList } + return { List: MockList } }) vi.mock('../../explore/try-app', () => ({ diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index c55bb20d5a2..7d3dc46f764 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -2,6 +2,7 @@ import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/syst import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types' import type { App } from '@/models/explore' import type { TryAppSelection } from '@/types/try-app' +import { keepPreviousData } from '@tanstack/react-query' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createStore, Provider as JotaiProvider } from 'jotai' @@ -15,7 +16,7 @@ import { createConsoleQueryWrapper } from '@/test/console/query-data' import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture' import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' -import List from '../list' +import { List } from '../list' vi.mock('react-i18next', async () => { const { createReactI18nextMock } = await import('@/test/i18n-mock') @@ -71,11 +72,9 @@ const mockLearnDifyApp = vi.hoisted( }) satisfies App, ) -const mockReplace = vi.fn() -const mockRouter = { replace: mockReplace } let mockSearchParams = new URLSearchParams('') vi.mock('@/next/navigation', () => ({ - useRouter: () => mockRouter, + useRouter: () => ({ replace: vi.fn() }), usePathname: () => '/apps', useSearchParams: () => mockSearchParams, })) @@ -89,10 +88,12 @@ vi.mock('@/service/client', () => ({ consoleQuery: { apps: { get: { + key: () => ['console', 'apps', 'get'], infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options), }, starred: { get: { + key: () => ['console', 'apps', 'starred', 'get'], queryOptions: (options: unknown) => mockAppStarredListQueryOptions(options), }, }, @@ -114,7 +115,6 @@ vi.mock('@/service/client', () => ({ }, })) -const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false) let mockWorkspacePermissionKeys = ['app.create_and_management'] vi.mock('@/context/account-state', async () => { @@ -138,23 +138,6 @@ vi.mock('@/context/provider-context', () => ({ }), })) -const mockSetKeywords = vi.fn() -const mockSetCreatorIDs = vi.fn() -const mockSetCategory = vi.fn() -const mockQueryState = { - category: 'all', - keywords: '', - creatorIDs: [] as string[], -} -vi.mock('../hooks/use-apps-query-state', () => ({ - useAppsQueryState: () => ({ - query: mockQueryState, - setCategory: mockSetCategory, - setKeywords: mockSetKeywords, - setCreatorIDs: mockSetCreatorIDs, - }), -})) - vi.mock('@/service/use-common', () => ({ useMembers: () => ({ data: { @@ -188,30 +171,24 @@ vi.mock('@/features/tag-management/components/tag-filter', () => ({ ), })) -let mockOnDSLFileDropped: ((file: File) => void) | null = null -let mockDragging = false -vi.mock('../hooks/use-dsl-drag-drop', () => ({ - useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => { - mockOnDSLFileDropped = onDSLFileDropped - return { dragging: mockDragging } - }, -})) - vi.mock('../hooks/use-workflow-online-users', () => ({ useWorkflowOnlineUsers: (options: unknown) => mockUseWorkflowOnlineUsers(options), })) -const mockRefetch = vi.fn() -const mockRefetchStarredAppList = vi.fn() const mockFetchNextPage = vi.fn() +let mockSystemFeatures: GetSystemFeaturesResponse | null = null const mockServiceState = { error: null as Error | null, hasNextPage: false, + isFetchNextPageError: false, isFetching: false, isLoading: false, isFetchingNextPage: false, + isPlaceholderData: false, } +let mockStarredIsLoading = false +let mockStarredError: Error | null = null const defaultAppData = { pages: [ @@ -270,36 +247,30 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useQuery: () => ({ - data: mockStarredAppData, - isLoading: false, - refetch: mockRefetchStarredAppList, - }), + useQuery: (options: { input?: unknown }) => + options.input + ? { + data: mockStarredIsLoading ? undefined : mockStarredAppData, + error: mockStarredError, + } + : { + data: mockSystemFeatures, + error: null, + }, + useSuspenseQuery: () => ({ data: mockSystemFeatures }), useInfiniteQuery: () => ({ - data: mockAppData, - isLoading: mockServiceState.isLoading, + data: mockServiceState.isLoading ? undefined : mockAppData, isFetching: mockServiceState.isFetching, + isFetchNextPageError: mockServiceState.isFetchNextPageError, isFetchingNextPage: mockServiceState.isFetchingNextPage, + isPlaceholderData: mockServiceState.isPlaceholderData, fetchNextPage: mockFetchNextPage, hasNextPage: mockServiceState.hasNextPage, error: mockServiceState.error, - refetch: mockRefetch, }), } }) -vi.mock('@/service/use-apps', () => ({ - normalizeAppPagination: (response: unknown) => response, - useDeleteAppMutation: () => ({ - mutateAsync: vi.fn(), - isPending: false, - }), - useToggleAppStarMutation: () => ({ - mutateAsync: vi.fn(), - isPending: false, - }), -})) - vi.mock('@/hooks/use-pay', () => ({ CheckModal: () => null, })) @@ -443,11 +414,10 @@ vi.mock('../app-card', () => ({ }), ) }, - AppCardActionBar: ({ app, onRefresh }: { app: { id: string }; onRefresh?: () => void }) => { + AppCardActionBar: ({ app }: { app: { id: string } }) => { return React.createElement('button', { 'data-testid': `app-card-action-bar-${app.id}`, type: 'button', - onClick: onRefresh, }) }, default: ({ app }: { app: { id: string; name: string } }) => { @@ -523,8 +493,6 @@ beforeAll(() => { } as unknown as typeof IntersectionObserver }) -// Render helper wrapping with shared nuqs testing helper plus a seeded -// systemFeatures cache so List can resolve its useSuspenseQuery. type RenderListOptions = { onCreateLearnDify?: (app: App) => void onTryLearnDify?: (params: TryAppSelection) => void @@ -533,9 +501,10 @@ type RenderListOptions = { const renderList = (searchParams = '', options: RenderListOptions = {}) => { mockSearchParams = new URLSearchParams(searchParams) - const { wrapper: ConsoleQueryWrapper } = createConsoleQueryWrapper({ + const { wrapper: ConsoleQueryWrapper, systemFeatures } = createConsoleQueryWrapper({ systemFeatures: { branding: { enabled: false }, ...options.systemFeatures }, }) + mockSystemFeatures = systemFeatures const store = createStore() seedRegisteredConsoleStateFixture(store) store.set(stepByStepTourSessionAtom, stepByStepTourSessionState) @@ -556,12 +525,14 @@ const renderList = (searchParams = '', options: RenderListOptions = {}) => { type AppListInfiniteOptions = { input: (pageParam: number) => { query: Record } getNextPageParam: (lastPage: { has_more: boolean; page: number }) => number | undefined + placeholderData?: unknown } type AppStarredListQueryOptions = { input: { query: Record } + placeholderData?: unknown } const openAppTypeSelect = async (user = userEvent.setup()) => { @@ -574,6 +545,15 @@ const openAppSortSelect = async (user = userEvent.setup()) => { return user } +const dropDSLFileOnStudioHeader = (file: File) => { + fireEvent.drop(screen.getByRole('button', { name: 'common.operation.create' }), { + dataTransfer: { + files: [file], + types: ['Files'], + }, + }) +} + const setActiveStudioStepByStepTour = ( activeGuideIndex: number, activeGuideGroup: @@ -593,17 +573,16 @@ describe('List', () => { beforeEach(() => { vi.clearAllMocks() stepByStepTourSessionState = {} - mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false) mockWorkspacePermissionKeys = ['app.create_and_management'] - mockDragging = false - mockOnDSLFileDropped = null mockServiceState.error = null mockServiceState.hasNextPage = false + mockServiceState.isFetchNextPageError = false + mockServiceState.isFetching = false mockServiceState.isLoading = false mockServiceState.isFetchingNextPage = false - mockQueryState.category = 'all' - mockQueryState.keywords = '' - mockQueryState.creatorIDs = [] + mockServiceState.isPlaceholderData = false + mockStarredIsLoading = false + mockStarredError = null mockAppData = defaultAppData mockStarredAppData = { data: [], @@ -613,57 +592,10 @@ describe('List', () => { has_more: false, } mockUseWorkflowOnlineUsers.mockClear() - mockRefetchStarredAppList.mockClear() intersectionCallbacks.length = 0 - localStorage.clear() }) describe('Rendering', () => { - it('should render app type select with all app types', async () => { - renderList() - await openAppTypeSelect() - - expect(await screen.findByRole('menuitemradio', { name: 'All' }))!.toBeInTheDocument() - expect(screen.queryByRole('menuitemradio', { name: 'Types' })).not.toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.workflow' }), - )!.toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.advanced' }), - )!.toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.chatbot' }), - )!.toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.agent' }), - )!.toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.newApp.completeApp' }), - )!.toBeInTheDocument() - }) - - it('should render search input', () => { - renderList() - expect( - screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }), - )!.toBeInTheDocument() - }) - - it('should render tag filter', () => { - renderList() - expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument() - }) - - it('should render creators filter', () => { - renderList() - expect(screen.getByRole('button', { name: 'Creators' }))!.toBeInTheDocument() - }) - - it('should render create button for editors', () => { - renderList() - expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument() - }) - it('should open the create menu before the Studio with-apps guide group is persisted', async () => { setActiveStudioStepByStepTour(0, undefined) @@ -771,10 +703,7 @@ describe('List', () => { allAppsLabel.compareDocumentPosition(firstAppCard) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() - fireEvent.click(actionBar) - - expect(mockRefetch).toHaveBeenCalledTimes(1) - expect(mockRefetchStarredAppList).toHaveBeenCalledTimes(1) + expect(actionBar).toBeEnabled() }) it('should expose the first workspace app card and open its action menu for the Studio with-apps tour manage guide', () => { @@ -1005,18 +934,44 @@ describe('List', () => { expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument() }) - it('should keep the regular empty state for empty filtered results', () => { + it('should not render first empty state from placeholder data during a filter transition', () => { mockAppData = { pages: [{ data: [], total: 0 }] } - mockQueryState.keywords = 'missing app' + mockServiceState.isPlaceholderData = true renderList() + expect(screen.getByTestId('empty-state')).toBeInTheDocument() + expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument() + }) + + it('should keep the regular empty state for empty filtered results', () => { + mockAppData = { pages: [{ data: [], total: 0 }] } + + renderList('?keywords=missing+app') + expect(screen.getByTestId('empty-state'))!.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument() expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument() expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument() }) + it('should leave the first empty state as soon as a filter changes', () => { + mockAppData = { pages: [{ data: [], total: 0 }] } + renderList() + + expect(screen.getByText('app.firstEmpty.title')).toBeInTheDocument() + + fireEvent.change( + screen.getByRole('searchbox', { + name: 'app.gotoAnything.actions.searchApplications', + }), + { target: { value: 'workflow' } }, + ) + + expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument() + expect(screen.getByTestId('empty-state')).toBeInTheDocument() + }) + it('should open create flows from first empty state actions', () => { mockAppData = { pages: [{ data: [], total: 0 }] } @@ -1066,74 +1021,79 @@ describe('List', () => { describe('App Type Select', () => { it('should render selected category in the trigger', () => { - mockQueryState.category = AppModeEnum.WORKFLOW - - renderList() + renderList('?category=workflow') expect(screen.getByRole('button', { name: 'app.types.workflow' }))!.toBeInTheDocument() }) + it('should reject API modes that the Studio category control does not support', () => { + renderList('?category=channel') + + expect(screen.getByRole('button', { name: 'Types' })).toBeInTheDocument() + const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions + expect(options.input(1).query).not.toHaveProperty('mode') + }) + it('should update category when workflow option is selected', async () => { const user = userEvent.setup() - renderList() + const { onUrlUpdate } = renderList() await openAppTypeSelect(user) await user.click(await screen.findByRole('menuitemradio', { name: 'app.types.workflow' })) - expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW) + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('category')).toBe( + AppModeEnum.WORKFLOW, + ) }) it('should update category when all option is selected', async () => { const user = userEvent.setup() - mockQueryState.category = AppModeEnum.WORKFLOW - renderList() + const { onUrlUpdate } = renderList('?category=workflow') await openAppTypeSelect(user) await user.click(await screen.findByRole('menuitemradio', { name: 'All' })) - expect(mockSetCategory).toHaveBeenCalledWith('all') + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has('category')).toBe(false) }) }) describe('Search Functionality', () => { - it('should render search input field', () => { - renderList() - expect( - screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }), - )!.toBeInTheDocument() - }) - - it('should handle search input change', () => { - renderList() - - const input = screen.getByRole('searchbox', { - name: 'app.gotoAnything.actions.searchApplications', - }) - fireEvent.change(input, { target: { value: 'test search' } }) - - expect(mockSetKeywords).toHaveBeenCalledWith('test search') - }) - - it('should handle search clear button click', () => { - mockQueryState.keywords = 'existing search' - - renderList() + it('should clear the keywords URL state', async () => { + const { onUrlUpdate } = renderList('?keywords=existing+search') const clearButton = document.querySelector('.i-ri-close-circle-fill')?.closest('button') expect(clearButton)!.toBeInTheDocument() if (clearButton) fireEvent.click(clearButton) - expect(mockSetKeywords).toHaveBeenCalledWith('') + expect(screen.getByRole('searchbox')).toHaveValue('') + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has('keywords')).toBe(false) + }) }) }) describe('App List Query', () => { - it('should build paged query input from active filters', () => { - mockQueryState.keywords = 'sales' - mockQueryState.creatorIDs = ['creator-1'] - mockQueryState.category = AppModeEnum.WORKFLOW - + it('resets the catalog scroll position before switching datasets', () => { renderList() + const searchBox = screen.getByRole('searchbox', { + name: 'app.gotoAnything.actions.searchApplications', + }) + const scrollContainer = screen.getByRole('region', { name: 'common.menus.apps' }) + expect(scrollContainer).not.toContainElement(searchBox) + const scrollTo = vi.fn() + scrollContainer.scrollTo = scrollTo + + fireEvent.click(screen.getByText('common.tag.placeholder')) + + expect(scrollTo).toHaveBeenCalledWith({ top: 0 }) + }) + + it('should build paged query input from active filters', () => { + renderList('?keywords=sales&category=workflow') + fireEvent.click(screen.getByRole('button', { name: 'Creators' })) + fireEvent.click(screen.getByText('Alice')) fireEvent.click(screen.getByText('common.tag.placeholder')) const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions @@ -1154,11 +1114,9 @@ describe('List', () => { }) it('should build starred query input from active filters with the starred limit', () => { - mockQueryState.keywords = 'sales' - mockQueryState.creatorIDs = ['creator-1'] - mockQueryState.category = AppModeEnum.WORKFLOW - - renderList() + renderList('?keywords=sales&category=workflow') + fireEvent.click(screen.getByRole('button', { name: 'Creators' })) + fireEvent.click(screen.getByText('Alice')) fireEvent.click(screen.getByText('common.tag.placeholder')) const options = mockAppStarredListQueryOptions.mock.calls.at( @@ -1177,43 +1135,39 @@ describe('List', () => { }, }) }) - }) - describe('Tag Filter', () => { - it('should render tag filter component', () => { + it('should keep previous main and starred data visible while filters refetch', async () => { renderList() - expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument() + + expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.tag.placeholder')) + + await waitFor(() => { + const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions + expect(options.input(1).query).toMatchObject({ tag_ids: ['tag-1'] }) + expect(options.placeholderData).toBe(keepPreviousData) + }) + const starredOptions = mockAppStarredListQueryOptions.mock.calls.at( + -1, + )?.[0] as AppStarredListQueryOptions + expect(starredOptions.placeholderData).toBe(keepPreviousData) + expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument() }) }) describe('Creators Filter', () => { - it('should render creators filter with correct label', () => { - renderList() - expect(screen.getByRole('button', { name: 'Creators' }))!.toBeInTheDocument() - }) - it('should handle creator selection', () => { renderList() fireEvent.click(screen.getByRole('button', { name: 'Creators' })) fireEvent.click(screen.getByRole('button', { name: /Bob/ })) - expect(mockSetCreatorIDs).toHaveBeenCalledWith(['creator-2']) + expect(screen.getByRole('button', { name: /Creators.*\+1/ })).toBeInTheDocument() }) }) describe('Create Menu', () => { - it('should render all create menu options', async () => { - renderList() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) - - expect(await screen.findByText('app.newApp.startFromBlank'))!.toBeInTheDocument() - expect(await screen.findByText('app.newApp.startFromTemplate'))!.toBeInTheDocument() - expect(await screen.findByText('app.importDSL'))!.toBeInTheDocument() - expect(await screen.findAllByText('app.newApp.dropDSLToCreateApp')).toHaveLength(2) - }) - it('should open blank app modal from create menu', async () => { renderList() @@ -1252,109 +1206,7 @@ describe('List', () => { }) }) - describe('User Without App Creation Permission', () => { - it('should not render new app card without app creation permission', () => { - mockWorkspacePermissionKeys = [] - - renderList() - - expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument() - }) - - it('should not render drop DSL hint without app creation permission', () => { - mockWorkspacePermissionKeys = [] - - renderList() - - expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument() - }) - }) - - describe('Dataset Operator Behavior', () => { - it('should not trigger redirect at component level for dataset operators', () => { - mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true) - - renderList() - - expect(mockReplace).not.toHaveBeenCalled() - }) - }) - - describe('Local Storage Refresh', () => { - it('should call refetch when refresh key is set in localStorage', () => { - localStorage.setItem('needRefreshAppList', '1') - - renderList() - - expect(mockRefetch).toHaveBeenCalled() - expect(localStorage.getItem('needRefreshAppList')).toBeNull() - }) - }) - - describe('Edge Cases', () => { - it('should handle multiple renders without issues', () => { - const { unmount } = renderList() - expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument() - - unmount() - renderList() - expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument() - }) - - it('should render app cards correctly', () => { - renderList() - - expect(screen.getByText('Test App 1'))!.toBeInTheDocument() - expect(screen.getByText('Test App 2'))!.toBeInTheDocument() - }) - - it('should render with all filter options visible', () => { - renderList() - - expect( - screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }), - )!.toBeInTheDocument() - expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Creators' }))!.toBeInTheDocument() - }) - }) - - describe('Dragging State', () => { - it('should show drop hint when DSL feature is enabled for editors', () => { - renderList() - expect(screen.getByText('app.newApp.dropDSLToCreateApp'))!.toBeInTheDocument() - }) - - it('should render dragging state overlay when dragging', () => { - mockDragging = true - const { container } = renderList() - expect(container)!.toBeInTheDocument() - }) - }) - describe('App Type Select Options', () => { - it('should render all app type options', async () => { - renderList() - await openAppTypeSelect() - - expect(await screen.findByRole('menuitemradio', { name: 'All' }))!.toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.workflow' }), - )!.toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.advanced' }), - )!.toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.chatbot' }), - )!.toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.agent' }), - )!.toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.newApp.completeApp' }), - )!.toBeInTheDocument() - }) - it('should update category for each app type option click', async () => { const appTypeTexts = [ { mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' }, @@ -1366,11 +1218,11 @@ describe('List', () => { for (const { mode, text } of appTypeTexts) { const user = userEvent.setup() - const { unmount } = renderList() + const { onUrlUpdate, unmount } = renderList() await openAppTypeSelect(user) - mockSetCategory.mockClear() await user.click(await screen.findByRole('menuitemradio', { name: text })) - expect(mockSetCategory).toHaveBeenCalledWith(mode) + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('category')).toBe(mode) unmount() } }) @@ -1387,30 +1239,12 @@ describe('List', () => { }) }) - describe('App List Display', () => { - it('should display all app cards from data', () => { - renderList() - - expect(screen.getByTestId('app-card-app-1'))!.toBeInTheDocument() - expect(screen.getByTestId('app-card-app-2'))!.toBeInTheDocument() - }) - - it('should display app names correctly', () => { - renderList() - - expect(screen.getByText('Test App 1'))!.toBeInTheDocument() - expect(screen.getByText('Test App 2'))!.toBeInTheDocument() - }) - }) - describe('DSL File Drop', () => { it('should handle DSL file drop and show modal', () => { renderList() const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) - act(() => { - if (mockOnDSLFileDropped) mockOnDSLFileDropped(mockFile) - }) + dropDSLFileOnStudioHeader(mockFile) expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument() }) @@ -1419,9 +1253,7 @@ describe('List', () => { renderList() const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) - act(() => { - if (mockOnDSLFileDropped) mockOnDSLFileDropped(mockFile) - }) + dropDSLFileOnStudioHeader(mockFile) expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument() @@ -1430,20 +1262,17 @@ describe('List', () => { expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument() }) - it('should close DSL modal and refetch when onSuccess is called', () => { + it('should close DSL modal when its mutation reports success', () => { renderList() const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) - act(() => { - if (mockOnDSLFileDropped) mockOnDSLFileDropped(mockFile) - }) + dropDSLFileOnStudioHeader(mockFile) expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument() fireEvent.click(screen.getByTestId('success-dsl-modal')) expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument() - expect(mockRefetch).toHaveBeenCalled() }) }) @@ -1484,29 +1313,53 @@ describe('List', () => { expect(mockFetchNextPage).not.toHaveBeenCalled() }) - it('should not call fetchNextPage when loading', () => { + it('should show one catalog skeleton until both app queries have data', () => { mockServiceState.hasNextPage = true mockServiceState.isLoading = true + const mainPending = renderList() + + expect( + screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }), + ).toBeInTheDocument() + expect(screen.getAllByRole('status', { name: 'common.loading' })).toHaveLength(1) + expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument() + expect(mockFetchNextPage).not.toHaveBeenCalled() + + mainPending.unmount() + mockServiceState.isLoading = false + mockStarredIsLoading = true renderList() - for (const callback of intersectionCallbacks) { - act(() => { - callback( - [{ isIntersecting: true } as IntersectionObserverEntry], - {} as IntersectionObserver, - ) - }) - } - - expect(mockFetchNextPage).not.toHaveBeenCalled() + expect(screen.getAllByRole('status', { name: 'common.loading' })).toHaveLength(1) + expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument() }) }) describe('Error State', () => { - it('should handle error state in useEffect', () => { + it('should keep the main catalog available when the starred request fails', () => { + mockStarredIsLoading = true + mockStarredError = new Error('Starred request failed') + + renderList() + + expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument() + expect(screen.queryByText('Starred')).not.toBeInTheDocument() + expect(screen.queryByRole('status', { name: 'common.loading' })).not.toBeInTheDocument() + }) + + it('should keep resolved data visible and retry a failed next page', async () => { + const user = userEvent.setup() mockServiceState.error = new Error('Test error') - const { container } = renderList() - expect(container)!.toBeInTheDocument() + mockServiceState.hasNextPage = true + mockServiceState.isFetchNextPageError = true + + renderList() + + expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument() + const retryButton = screen.getByRole('button', { name: 'common.operation.retry' }) + await user.click(retryButton) + + expect(mockFetchNextPage).toHaveBeenCalledWith({ cancelRefetch: false }) }) }) }) diff --git a/web/app/components/apps/app-card-skeleton.tsx b/web/app/components/apps/app-card-skeleton.tsx index 8917ad61c5d..977ff7a3407 100644 --- a/web/app/components/apps/app-card-skeleton.tsx +++ b/web/app/components/apps/app-card-skeleton.tsx @@ -39,5 +39,3 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps) ) }) - -AppCardSkeleton.displayName = 'AppCardSkeleton' diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index bd41defa080..1a88fa1c834 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -1,11 +1,14 @@ 'use client' -import type { EnvironmentVariableItemResponse } from '@dify/contracts/api/console/apps/types.gen' -import type { FormEvent, FormEventHandler, KeyboardEvent, MouseEvent } from 'react' +import type { + AppPartial, + EnvironmentVariableItemResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import type { FormEventHandler, MouseEvent } from 'react' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { WorkflowOnlineUser } from '@/models/app' -import type { App } from '@/types/app' +import { zIconType } from '@dify/contracts/api/console/apps/zod.gen' import { AlertDialog, AlertDialogActions, @@ -26,13 +29,12 @@ import { import { Field, FieldControl, FieldLabel } from '@langgenius/dify-ui/field' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' -import { useCallback, useId, useMemo, useState } from 'react' +import { memo, useCallback, useId, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { AppTypeIcon } from '@/app/components/app/type-selector' import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/use-export-app-dsl' -import { useSetNeedRefreshAppList } from '@/app/components/apps/storage' import AppIcon from '@/app/components/base/app-icon' import StarIcon from '@/app/components/base/icons/src/vender/Star' import { UserAvatarList } from '@/app/components/base/user-avatar-list' @@ -47,14 +49,13 @@ import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { AppCardTags } from '@/features/tag-management/components/app-card-tags' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' -import { AccessMode } from '@/models/access-control' +import { AccessMode, isAccessMode } from '@/models/access-control' import dynamic from '@/next/dynamic' import Link from '@/next/link' import { useRouter } from '@/next/navigation' import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' -import { copyApp, updateAppInfo } from '@/service/apps' +import { consoleQuery } from '@/service/client' import { fetchInstalledAppList } from '@/service/explore' -import { useDeleteAppMutation, useToggleAppStarMutation } from '@/service/use-apps' import { AppModeEnum } from '@/types/app' import { getRedirection, getRedirectionPath } from '@/utils/app-redirection' import { @@ -98,16 +99,16 @@ const ACCESS_MODE_LABEL_KEYS = { [AccessMode.EXTERNAL_MEMBERS]: 'accessItemsDescription.external', } as const -const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set([ +const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set([ AppModeEnum.ADVANCED_CHAT, AppModeEnum.WORKFLOW, ]) const OPERATIONS_MENU_POPUP_CLASS_NAME = 'min-w-[216px]' +const EMPTY_ONLINE_USERS: WorkflowOnlineUser[] = [] type AppCardProps = { - app: App + app: AppPartial onlineUsers?: WorkflowOnlineUser[] - onRefresh?: () => void onOpenTagManagement?: () => void stepByStepTourActionMenuOpen?: boolean stepByStepTourCardTarget?: string @@ -119,9 +120,7 @@ type AppAccessModeIconProps = { accessMode?: AccessMode | null } -const getAppResourceMaintainer = (app: App) => app.maintainer - -function requiresPublishedWorkflowInExplore(app: App) { +function requiresPublishedWorkflowInExplore(app: AppPartial) { return APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE.has(app.mode) } @@ -156,7 +155,7 @@ function AppAccessModeIcon({ accessMode }: AppAccessModeIconProps) { } type AppCardOperationsMenuProps = { - app: App + app: AppPartial shouldShowEditOption: boolean shouldShowDuplicateOption: boolean shouldShowExportOption: boolean @@ -349,615 +348,161 @@ function AppCardOperationsMenuContent(props: AppCardOperationsMenuContentProps) } type AppCardActionBarProps = { - app: App - onRefresh?: () => void + app: AppPartial + stepByStepTourActionMenuOpen?: boolean + stepByStepTourActionMenuHighlightPart?: string } -export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { - const { t } = useTranslation() - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const currentUserId = useAtomValue(userProfileIdAtom) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const isRbacEnabled = systemFeatures.rbac_enabled - const { onPlanInfoChanged } = useProviderContext() - const { push } = useRouter() +export const AppCardActionBar = memo( + ({ + app, + stepByStepTourActionMenuOpen = false, + stepByStepTourActionMenuHighlightPart, + }: AppCardActionBarProps) => { + const { t } = useTranslation() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const currentUserId = useAtomValue(userProfileIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + const isRbacEnabled = systemFeatures.rbac_enabled + const { onPlanInfoChanged } = useProviderContext() + const { push } = useRouter() + const { mutate: copyApp } = useMutation(consoleQuery.apps.byAppId.copy.post.mutationOptions()) + const { mutateAsync: updateApp } = useMutation(consoleQuery.apps.byAppId.put.mutationOptions()) + const { mutate: deleteApp, isPending: isDeleting } = useMutation( + consoleQuery.apps.byAppId.delete.mutationOptions(), + ) + const { mutate: starApp, isPending: isStarring } = useMutation( + consoleQuery.apps.byAppId.star.post.mutationOptions(), + ) + const { mutate: unstarApp, isPending: isUnstarring } = useMutation( + consoleQuery.apps.byAppId.star.delete.mutationOptions(), + ) - const [showEditModal, setShowEditModal] = useState(false) - const [showDuplicateModal, setShowDuplicateModal] = useState(false) - const [showSwitchModal, setShowSwitchModal] = useState(false) - const [showConfirmDelete, setShowConfirmDelete] = useState(false) - const [confirmDeleteInput, setConfirmDeleteInput] = useState('') - const [showAccessControl, setShowAccessControl] = useState(false) - const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false) - const [secretEnvList, setSecretEnvList] = useState([]) - const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation() - const { mutateAsync: mutateToggleAppStar, isPending: isTogglingStar } = useToggleAppStarMutation() - const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl() - const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } = useExportWorkflowAppDsl() - const isExporting = isAppDslExporting || isWorkflowAppDslExporting - const setNeedRefresh = useSetNeedRefreshAppList() - const resourceMaintainer = getAppResourceMaintainer(app) - const maintainerPermissionOptions = useMemo( - () => ({ - currentUserId, - resourceMaintainer, - workspacePermissionKeys, - isRbacEnabled, - }), - [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys], - ) - const appACLCapabilities = useMemo( - () => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), - [app.permission_keys, maintainerPermissionOptions], - ) - const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) - const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') - - const onConfirmDelete = useCallback(async () => { - try { - await mutateDeleteApp(app.id) - toast.success(t(($) => $.appDeleted, { ns: 'app' })) - onPlanInfoChanged() - setShowConfirmDelete(false) - setConfirmDeleteInput('') - } catch (e) { - const message = e instanceof Error ? e.message : '' - toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`) - } - }, [app.id, mutateDeleteApp, onPlanInfoChanged, t]) - - const onDeleteDialogOpenChange = useCallback( - (open: boolean) => { - if (isDeleting) return - - setShowConfirmDelete(open) - if (!open) setConfirmDeleteInput('') - }, - [isDeleting], - ) - - const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name - - const onDeleteDialogSubmit: FormEventHandler = useCallback( - (e) => { - e.preventDefault() - if (isDeleteConfirmDisabled) return - - void onConfirmDelete() - }, - [isDeleteConfirmDisabled, onConfirmDelete], - ) - - const handleShowEditModal = useCallback(() => { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowEditModal(true) + const [showEditModal, setShowEditModal] = useState(false) + const [showDuplicateModal, setShowDuplicateModal] = useState(false) + const [showSwitchModal, setShowSwitchModal] = useState(false) + const [showConfirmDelete, setShowConfirmDelete] = useState(false) + const [confirmDeleteInput, setConfirmDeleteInput] = useState('') + const [showAccessControl, setShowAccessControl] = useState(false) + const operationsMenu = useStepByStepTourControlledDropdown({ + allowTriggerCloseWhileControlled: false, + controlledOpen: stepByStepTourActionMenuOpen, }) - }, []) - - const handleShowDuplicateModal = useCallback(() => { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowDuplicateModal(true) - }) - }, []) - - const handleShowSwitchModal = useCallback(() => { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowSwitchModal(true) - }) - }, []) - - const handleShowDeleteConfirm = useCallback(() => { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowConfirmDelete(true) - }) - }, []) - - const handleShowAccessControl = useCallback(() => { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowAccessControl(true) - }) - }, []) - - const handleOpenAccessConfig = useCallback(() => { - setIsOperationsMenuOpen(false) - push(`/app/${app.id}/access-config`) - }, [app.id, push]) - - const onEdit: CreateAppModalProps['onConfirm'] = useCallback( - async ({ - name, - icon_type, - icon, - icon_background, - description, - use_icon_as_answer_icon, - max_active_requests, - }) => { - try { - await updateAppInfo({ - appID: app.id, - name, - icon_type, - icon, - icon_background, - description, - use_icon_as_answer_icon, - max_active_requests, - }) - setShowEditModal(false) - toast.success(t(($) => $.editDone, { ns: 'app' })) - onRefresh?.() - } catch (e) { - toast.error(e instanceof Error ? e.message : t(($) => $.editFailed, { ns: 'app' })) - } - }, - [app.id, onRefresh, t], - ) - - const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ - name, - icon_type, - icon, - icon_background, - }) => { - try { - const newApp = await copyApp({ - appID: app.id, - name, - icon_type, - icon, - icon_background, - mode: app.mode, - }) - setShowDuplicateModal(false) - toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) - setNeedRefresh('1') - onRefresh?.() - onPlanInfoChanged() - getRedirection(newApp, push, { + const isOperationsMenuOpen = operationsMenu.open + const setIsOperationsMenuOpen = operationsMenu.onOpenChange + const [secretEnvList, setSecretEnvList] = useState([]) + const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl() + const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } = + useExportWorkflowAppDsl() + const isExporting = isAppDslExporting || isWorkflowAppDslExporting + const isTogglingStar = isStarring || isUnstarring + const appIconType = zIconType.safeParse(app.icon_type).data ?? null + const resourceMaintainer = app.maintainer ?? undefined + const maintainerPermissionOptions = useMemo( + () => ({ currentUserId, - resourceMaintainer: getAppResourceMaintainer(newApp), + resourceMaintainer, workspacePermissionKeys, isRbacEnabled, - }) - } catch { - toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) - } - } - - const onExport = async (include = false) => { - await exportAppDsl({ appId: app.id, appName: app.name, includeSecret: include }) - } - - const exportCheck = async () => { - if (isExporting) return - - setIsOperationsMenuOpen(false) - const isWorkflowApp = - app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT - const result = isWorkflowApp - ? await exportWorkflowAppDsl({ appId: app.id, appName: app.name }) - : await exportAppDsl({ appId: app.id, appName: app.name }) - if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList) - } - - const onSwitch = () => { - onRefresh?.() - setShowSwitchModal(false) - } - - const onUpdateAccessControl = useCallback(() => { - onRefresh?.() - setShowAccessControl(false) - }, [onRefresh, setShowAccessControl]) - - const handleToggleStar = useCallback( - async (e: MouseEvent) => { - e.stopPropagation() - e.preventDefault() - - if (isTogglingStar) return + }), + [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys], + ) + const appACLCapabilities = useMemo( + () => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), + [app.permission_keys, maintainerPermissionOptions], + ) + const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) + const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') + const onConfirmDelete = useCallback(() => { try { - await mutateToggleAppStar({ - appId: app.id, - isStarred: Boolean(app.is_starred), - }) - onRefresh?.() - } catch (error) { - toast.error( - error instanceof Error ? error.message : t(($) => $['studio.starFailed'], { ns: 'app' }), + deleteApp( + { params: { app_id: app.id } }, + { + onSuccess: () => { + toast.success(t(($) => $.appDeleted, { ns: 'app' })) + onPlanInfoChanged() + setShowConfirmDelete(false) + setConfirmDeleteInput('') + }, + onError: (error) => { + const message = error instanceof Error ? error.message : '' + toast.error( + `${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`, + ) + }, + }, ) + } catch (error) { + const message = error instanceof Error ? error.message : '' + toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`) } - }, - [app.id, app.is_starred, isTogglingStar, mutateToggleAppStar, onRefresh, t], - ) + }, [app.id, deleteApp, onPlanInfoChanged, t]) - const shouldShowEditOption = appACLCapabilities.canEdit - const shouldShowDuplicateOption = canCreateApp - const shouldShowExportOption = appACLCapabilities.canImportExportDSL - const shouldShowSwitchOption = - canCreateApp && - appACLCapabilities.canEdit && - (app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) - const shouldShowAccessControlOption = - systemFeatures.webapp_auth.enabled && appACLCapabilities.canReleaseAndVersion - const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig - const shouldShowDeleteOption = appACLCapabilities.canDelete - const shouldShowOperationsMenu = - shouldShowEditOption || - shouldShowDuplicateOption || - shouldShowExportOption || - shouldShowSwitchOption || - shouldShowAccessControlOption || - shouldShowAccessConfigOption || - shouldShowDeleteOption - const starActionLabel = app.is_starred - ? t(($) => $['studio.unstarApp'], { ns: 'app' }) - : t(($) => $['studio.starApp'], { ns: 'app' }) + const onDeleteDialogOpenChange = useCallback( + (open: boolean) => { + if (isDeleting) return - return ( - <> - {!isPreviewOnly && ( -
- - - - - } - /> - {starActionLabel} - - {shouldShowOperationsMenu && ( - - $['operation.exporting'], { ns: 'common' }) - : t(($) => $['operation.moreActionsFor'], { - ns: 'common', - name: app.name, - }) - } - disabled={isExporting} - className={cn( - 'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed data-popup-open:bg-state-base-hover', - )} - onClick={(e) => { - e.stopPropagation() - e.preventDefault() - }} - > - - - - {systemFeatures.webapp_auth.enabled ? ( - - ) : ( - - )} - - - )} -
- )} - {showEditModal && ( - setShowEditModal(false)} - /> - )} - {showDuplicateModal && ( - setShowDuplicateModal(false)} - /> - )} - {showSwitchModal && ( - setShowSwitchModal(false)} - onSuccess={onSwitch} - /> - )} - - -
-
- - {t(($) => $.deleteAppConfirmTitle, { ns: 'app' })} - - - {t(($) => $.deleteAppConfirmContent, { ns: 'app' })} - - - - $.deleteAppConfirmInputLabel} - ns="app" - values={{ appName: app.name }} - components={{ - appName: ( - - ), - }} - /> - - $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} - value={confirmDeleteInput} - onValueChange={setConfirmDeleteInput} - className="border-components-input-border-hover bg-components-input-bg-normal focus:border-components-input-border-active focus:bg-components-input-bg-active" - /> - -
- - - {t(($) => $['operation.cancel'], { ns: 'common' })} - - - {t(($) => $['operation.confirm'], { ns: 'common' })} - - -
-
-
- {secretEnvList.length > 0 && ( - setSecretEnvList([])} - /> - )} - {showAccessControl && ( - setShowAccessControl(false)} - /> - )} - - ) -} + setShowConfirmDelete(open) + if (!open) setConfirmDeleteInput('') + }, + [isDeleting], + ) -export function AppCard({ - app, - onlineUsers = [], - onRefresh, - onOpenTagManagement = () => {}, - stepByStepTourActionMenuOpen = false, - stepByStepTourCardTarget, - stepByStepTourCardHighlightPart, - stepByStepTourActionMenuHighlightPart, -}: AppCardProps) { - const { t } = useTranslation() - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const currentUserId = useAtomValue(userProfileIdAtom) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const isRbacEnabled = systemFeatures.rbac_enabled - const { onPlanInfoChanged } = useProviderContext() - const { push } = useRouter() + const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name - const [showEditModal, setShowEditModal] = useState(false) - const [showDuplicateModal, setShowDuplicateModal] = useState(false) - const [showSwitchModal, setShowSwitchModal] = useState(false) - const [showConfirmDelete, setShowConfirmDelete] = useState(false) - const [confirmDeleteInput, setConfirmDeleteInput] = useState('') - const [showAccessControl, setShowAccessControl] = useState(false) - const operationsMenu = useStepByStepTourControlledDropdown({ - allowTriggerCloseWhileControlled: false, - controlledOpen: stepByStepTourActionMenuOpen, - }) - const isOperationsMenuOpen = operationsMenu.open - const setIsOperationsMenuOpen = operationsMenu.onOpenChange - const [secretEnvList, setSecretEnvList] = useState([]) - const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation() - const { mutateAsync: mutateToggleAppStar, isPending: isTogglingStar } = useToggleAppStarMutation() - const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl() - const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } = useExportWorkflowAppDsl() - const isExporting = isAppDslExporting || isWorkflowAppDslExporting - const setNeedRefresh = useSetNeedRefreshAppList() - const resourceMaintainer = getAppResourceMaintainer(app) - const maintainerPermissionOptions = useMemo( - () => ({ - currentUserId, - resourceMaintainer, - workspacePermissionKeys, - isRbacEnabled, - }), - [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys], - ) - const appACLCapabilities = useMemo( - () => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), - [app.permission_keys, maintainerPermissionOptions], - ) - const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) - const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') - const canManageAppTags = hasPermission(workspacePermissionKeys, 'app.tag.manage') + const onDeleteDialogSubmit: FormEventHandler = useCallback( + (e) => { + e.preventDefault() + if (isDeleteConfirmDisabled) return - async function onConfirmDelete() { - try { - await mutateDeleteApp(app.id) - toast.success(t(($) => $.appDeleted, { ns: 'app' })) - onPlanInfoChanged() - setShowConfirmDelete(false) - setConfirmDeleteInput('') - } catch (e) { - const message = e instanceof Error ? e.message : '' - toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`) - } - } + void onConfirmDelete() + }, + [isDeleteConfirmDisabled, onConfirmDelete], + ) - function onDeleteDialogOpenChange(open: boolean) { - if (isDeleting) return + const handleShowEditModal = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setShowEditModal(true) + }) + }, [setIsOperationsMenuOpen]) - setShowConfirmDelete(open) - if (!open) setConfirmDeleteInput('') - } + const handleShowDuplicateModal = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setShowDuplicateModal(true) + }) + }, [setIsOperationsMenuOpen]) - const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name + const handleShowSwitchModal = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setShowSwitchModal(true) + }) + }, [setIsOperationsMenuOpen]) - function onDeleteDialogSubmit(event: FormEvent) { - event.preventDefault() - if (isDeleteConfirmDisabled) return + const handleShowDeleteConfirm = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setShowConfirmDelete(true) + }) + }, [setIsOperationsMenuOpen]) - void onConfirmDelete() - } + const handleShowAccessControl = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setShowAccessControl(true) + }) + }, [setIsOperationsMenuOpen]) - function handleShowEditModal() { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowEditModal(true) - }) - } + const handleOpenAccessConfig = useCallback(() => { + setIsOperationsMenuOpen(false) + push(`/app/${app.id}/access-config`) + }, [app.id, push, setIsOperationsMenuOpen]) - function handleShowDuplicateModal() { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowDuplicateModal(true) - }) - } - - function handleShowSwitchModal() { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowSwitchModal(true) - }) - } - - function handleShowDeleteConfirm() { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowConfirmDelete(true) - }) - } - - function handleShowAccessControl() { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowAccessControl(true) - }) - } - - const handleOpenAccessConfig = useCallback(() => { - setIsOperationsMenuOpen(false) - push(`/app/${app.id}/access-config`) - }, [app.id, push]) - - const onEdit: CreateAppModalProps['onConfirm'] = async ({ - name, - icon_type, - icon, - icon_background, - description, - use_icon_as_answer_icon, - max_active_requests, - }) => { - try { - await updateAppInfo({ - appID: app.id, + const onEdit: CreateAppModalProps['onConfirm'] = useCallback( + async ({ name, icon_type, icon, @@ -965,268 +510,147 @@ export function AppCard({ description, use_icon_as_answer_icon, max_active_requests, - }) - setShowEditModal(false) - toast.success(t(($) => $.editDone, { ns: 'app' })) - if (onRefresh) onRefresh() - } catch (e) { - toast.error(e instanceof Error ? e.message : t(($) => $.editFailed, { ns: 'app' })) - } - } - - const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ - name, - icon_type, - icon, - icon_background, - }) => { - try { - const newApp = await copyApp({ - appID: app.id, - name, - icon_type, - icon, - icon_background, - mode: app.mode, - }) - setShowDuplicateModal(false) - toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) - setNeedRefresh('1') - if (onRefresh) onRefresh() - onPlanInfoChanged() - getRedirection(newApp, push, { - currentUserId, - resourceMaintainer: getAppResourceMaintainer(newApp), - workspacePermissionKeys, - isRbacEnabled, - }) - } catch { - toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) - } - } - - const onExport = async (include = false) => { - await exportAppDsl({ appId: app.id, appName: app.name, includeSecret: include }) - } - - const exportCheck = async () => { - if (isExporting) return - - setIsOperationsMenuOpen(false) - const isWorkflowApp = - app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT - const result = isWorkflowApp - ? await exportWorkflowAppDsl({ appId: app.id, appName: app.name }) - : await exportAppDsl({ appId: app.id, appName: app.name }) - if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList) - } - - const onSwitch = () => { - if (onRefresh) onRefresh() - setShowSwitchModal(false) - } - - function onUpdateAccessControl() { - if (onRefresh) onRefresh() - setShowAccessControl(false) - } - - const handleToggleStar = useCallback( - async (e: React.MouseEvent) => { - e.stopPropagation() - e.preventDefault() - - if (isTogglingStar) return - - try { - await mutateToggleAppStar({ - appId: app.id, - isStarred: Boolean(app.is_starred), - }) - onRefresh?.() - } catch (error) { - toast.error( - error instanceof Error ? error.message : t(($) => $['studio.starFailed'], { ns: 'app' }), - ) - } - }, - [app.id, app.is_starred, isTogglingStar, mutateToggleAppStar, onRefresh, t], - ) - - const shouldShowEditOption = appACLCapabilities.canEdit - const shouldShowDuplicateOption = canCreateApp - const shouldShowExportOption = appACLCapabilities.canImportExportDSL - const shouldShowSwitchOption = - appACLCapabilities.canEdit && - (app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) - const shouldShowAccessControlOption = - systemFeatures.webapp_auth.enabled && appACLCapabilities.canReleaseAndVersion - const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig - const shouldShowDeleteOption = appACLCapabilities.canDelete - const shouldShowOperationsMenu = - shouldShowEditOption || - shouldShowDuplicateOption || - shouldShowExportOption || - shouldShowSwitchOption || - shouldShowAccessControlOption || - shouldShowAccessConfigOption || - shouldShowDeleteOption - const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || appACLCapabilities.canEdit) - const editTimeText = useMemo(() => { - const timeText = formatTime({ - date: (app.updated_at || app.created_at) * 1000, - dateFormat: `${t(($) => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, - }) - return `${t(($) => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}` - }, [app.updated_at, app.created_at, t]) - - const appModeLabel = useMemo(() => { - switch (app.mode) { - case AppModeEnum.CHAT: - return t(($) => $['types.chatbot'], { ns: 'app' }) - case AppModeEnum.ADVANCED_CHAT: - return t(($) => $['types.advanced'], { ns: 'app' }) - case AppModeEnum.AGENT_CHAT: - return t(($) => $['types.agent'], { ns: 'app' }) - case AppModeEnum.COMPLETION: - return t(($) => $['types.completion'], { ns: 'app' }) - case AppModeEnum.WORKFLOW: - return t(($) => $['types.workflow'], { ns: 'app' }) - default: - return app.mode - } - }, [app.mode, t]) - - const onlinePresenceUsers = useMemo(() => { - return onlineUsers - .map((user, index) => { - const id = user.user_id || user.sid || `${app.id}-online-${index}` - const name = user.username || user.user_id || user.sid || `${index + 1}` - return { - id, - name, - avatar_url: user.avatar || null, + }) => { + try { + await updateApp({ + params: { app_id: app.id }, + body: { + name, + icon_type, + icon, + icon_background, + description, + use_icon_as_answer_icon, + max_active_requests, + }, + }) + setShowEditModal(false) + toast.success(t(($) => $.editDone, { ns: 'app' })) + } catch (e) { + toast.error(e instanceof Error ? e.message : t(($) => $.editFailed, { ns: 'app' })) } - }) - .filter((user) => Boolean(user.id)) - }, [app.id, onlineUsers]) - const appNameId = useId() - const appDescriptionId = useId() - const appHref = getRedirectionPath(app, maintainerPermissionOptions) - const appCardClassName = cn( - 'inline-flex h-full w-full touch-manipulation flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs outline-hidden transition-shadow duration-200 ease-in-out', - isPreviewOnly - ? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid' - : 'cursor-pointer hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid', - ) - const starActionLabel = app.is_starred - ? t(($) => $['studio.unstarApp'], { ns: 'app' }) - : t(($) => $['studio.starApp'], { ns: 'app' }) - const showPreviewOnlyAccessWarning = useCallback(() => { - toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) - }, [t]) - const handlePreviewOnlyCardKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.key !== 'Enter' && event.key !== ' ') return + }, + [app.id, t, updateApp], + ) - event.preventDefault() - showPreviewOnlyAccessWarning() - }, - [showPreviewOnlyAccessWarning], - ) - const appCardContent = ( - <> -
-
- - -
-
-
-
- {app.name} -
-
-
- {appModeLabel} -
-
- {onlinePresenceUsers.length > 0 && ( -
- -
- )} -
-
-
- {app.description} -
-
-
-
-
- {app.author_name && ( - <> -
{app.author_name}
-
·
- - )} -
{editTimeText}
-
-
- - ) + const onCopy: DuplicateAppModalProps['onConfirm'] = ({ + name, + icon_type, + icon, + icon_background, + }) => { + try { + copyApp( + { + params: { app_id: app.id }, + body: { + name, + icon_type, + icon, + icon_background, + }, + }, + { + onSuccess: (newApp) => { + if (!('mode' in newApp)) { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) + return + } - return ( - <> -
- {isPreviewOnly ? ( -
- {appCardContent} -
- ) : ( - - {appCardContent} - - )} - + setShowDuplicateModal(false) + toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) + onPlanInfoChanged() + getRedirection(newApp, push, { + currentUserId, + resourceMaintainer: newApp.maintainer ?? undefined, + workspacePermissionKeys, + isRbacEnabled, + }) + }, + onError: () => toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })), + }, + ) + } catch { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) + } + return Promise.resolve() + } + + const onExport = async (include = false) => { + await exportAppDsl({ appId: app.id, appName: app.name, includeSecret: include }) + } + + const exportCheck = async () => { + if (isExporting) return + + setIsOperationsMenuOpen(false) + const isWorkflowApp = + app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT + const result = isWorkflowApp + ? await exportWorkflowAppDsl({ appId: app.id, appName: app.name }) + : await exportAppDsl({ appId: app.id, appName: app.name }) + if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList) + } + + const onUpdateAccessControl = useCallback(() => { + setShowAccessControl(false) + }, []) + + const handleToggleStar = useCallback( + (e: MouseEvent) => { + e.stopPropagation() + e.preventDefault() + + if (isTogglingStar) return + + const mutateStar = app.is_starred ? unstarApp : starApp + try { + mutateStar( + { params: { app_id: app.id } }, + { + onError: (error) => + toast.error( + error instanceof Error + ? error.message + : t(($) => $['studio.starFailed'], { ns: 'app' }), + ), + }, + ) + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : t(($) => $['studio.starFailed'], { ns: 'app' }), + ) + } + }, + [app.id, app.is_starred, isTogglingStar, starApp, t, unstarApp], + ) + + const shouldShowEditOption = appACLCapabilities.canEdit + const shouldShowDuplicateOption = canCreateApp + const shouldShowExportOption = appACLCapabilities.canImportExportDSL + const shouldShowSwitchOption = + appACLCapabilities.canEdit && + (app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) + const shouldShowAccessControlOption = + systemFeatures.webapp_auth.enabled && appACLCapabilities.canReleaseAndVersion + const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig + const shouldShowDeleteOption = appACLCapabilities.canDelete + const shouldShowOperationsMenu = + shouldShowEditOption || + shouldShowDuplicateOption || + shouldShowExportOption || + shouldShowSwitchOption || + shouldShowAccessControlOption || + shouldShowAccessConfigOption || + shouldShowDeleteOption + const starActionLabel = app.is_starred + ? t(($) => $['studio.unstarApp'], { ns: 'app' }) + : t(($) => $['studio.starApp'], { ns: 'app' }) + + return ( + <> {!isPreviewOnly && (
{ e.stopPropagation() e.preventDefault() @@ -1299,122 +725,88 @@ export function AppCard({ popupClassName: OPERATIONS_MENU_POPUP_CLASS_NAME, })} > - {systemFeatures.webapp_auth.enabled ? ( - - ) : ( - - )} + )}
)} -
- setShowEditModal(false)} /> -
-
- {showEditModal && ( - setShowEditModal(false)} - /> - )} - {showDuplicateModal && ( - setShowDuplicateModal(false)} - /> - )} - {showSwitchModal && ( - setShowSwitchModal(false)} - onSuccess={onSwitch} - /> - )} - - -
-
- - {t(($) => $.deleteAppConfirmTitle, { ns: 'app' })} - - - {t(($) => $.deleteAppConfirmContent, { ns: 'app' })} - - - - $.deleteAppConfirmInputLabel} - ns="app" - values={{ appName: app.name }} - components={{ - appName: ( - - ), - }} - /> - -
+ )} + {showDuplicateModal && ( + setShowDuplicateModal(false)} + /> + )} + {showSwitchModal && ( + setShowSwitchModal(false)} + /> + )} + + + +
+ + {t(($) => $.deleteAppConfirmTitle, { ns: 'app' })} + + + {t(($) => $.deleteAppConfirmContent, { ns: 'app' })} + + + + $.deleteAppConfirmInputLabel} + ns="app" + values={{ appName: app.name }} + components={{ + appName: ( + + ), + }} + /> + $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} value={confirmDeleteInput} onValueChange={setConfirmDeleteInput} - className="border-components-input-border-hover bg-components-input-bg-normal pr-20 focus:border-components-input-border-active focus:bg-components-input-bg-active" + className="border-components-input-border-hover bg-components-input-bg-normal focus:border-components-input-border-active focus:bg-components-input-bg-active" /> - -
- + +
+ + + {t(($) => $['operation.cancel'], { ns: 'common' })} + + + {t(($) => $['operation.confirm'], { ns: 'common' })} + + + + + + {secretEnvList.length > 0 && ( + setSecretEnvList([])} + /> + )} + {showAccessControl && ( + setShowAccessControl(false)} + /> + )} + + ) + }, +) + +export const AppCard = memo( + ({ + app, + onlineUsers = EMPTY_ONLINE_USERS, + onOpenTagManagement, + stepByStepTourActionMenuOpen = false, + stepByStepTourCardTarget, + stepByStepTourCardHighlightPart, + stepByStepTourActionMenuHighlightPart, + }: AppCardProps) => { + const { t } = useTranslation() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const currentUserId = useAtomValue(userProfileIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + const isRbacEnabled = systemFeatures.rbac_enabled + const resourceMaintainer = app.maintainer ?? undefined + const maintainerPermissionOptions = useMemo( + () => ({ + currentUserId, + resourceMaintainer, + workspacePermissionKeys, + isRbacEnabled, + }), + [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys], + ) + const appACLCapabilities = useMemo( + () => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), + [app.permission_keys, maintainerPermissionOptions], + ) + const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) + const canManageAppTags = hasPermission(workspacePermissionKeys, 'app.tag.manage') + const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || appACLCapabilities.canEdit) + const editTimeText = useMemo(() => { + const timestamp = app.updated_at || app.created_at + if (!timestamp) return '' + + const timeText = formatTime({ + date: timestamp * 1000, + dateFormat: `${t(($) => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, + }) + return `${t(($) => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}` + }, [app.updated_at, app.created_at, t]) + + const appModeLabel = useMemo(() => { + switch (app.mode) { + case AppModeEnum.CHAT: + return t(($) => $['types.chatbot'], { ns: 'app' }) + case AppModeEnum.ADVANCED_CHAT: + return t(($) => $['types.advanced'], { ns: 'app' }) + case AppModeEnum.AGENT_CHAT: + return t(($) => $['types.agent'], { ns: 'app' }) + case AppModeEnum.COMPLETION: + return t(($) => $['types.completion'], { ns: 'app' }) + case AppModeEnum.WORKFLOW: + return t(($) => $['types.workflow'], { ns: 'app' }) + default: + return app.mode + } + }, [app.mode, t]) + + const onlinePresenceUsers = useMemo(() => { + return onlineUsers + .map((user, index) => { + const id = user.user_id || user.sid || `${app.id}-online-${index}` + const name = user.username || user.user_id || user.sid || `${index + 1}` + return { + id, + name, + avatar_url: user.avatar || null, + } + }) + .filter((user) => Boolean(user.id)) + }, [app.id, onlineUsers]) + const appNameId = useId() + const appDescriptionId = useId() + const appIconType = zIconType.safeParse(app.icon_type).data ?? null + const appHref = getRedirectionPath(app, maintainerPermissionOptions) + const appCardClassName = cn( + 'inline-flex h-full w-full touch-manipulation flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs outline-hidden transition-shadow duration-200 ease-in-out', + isPreviewOnly + ? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid' + : 'cursor-pointer hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid', + ) + const showPreviewOnlyAccessWarning = useCallback(() => { + toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) + }, [t]) + const appCardContent = ( + <> +
+
+ + +
+
+
+
+ {app.name} +
- - - {t(($) => $['operation.cancel'], { ns: 'common' })} - - - {t(($) => $['operation.confirm'], { ns: 'common' })} - - - - - - {secretEnvList.length > 0 && ( - setSecretEnvList([])} +
+ {appModeLabel} +
+
+ {onlinePresenceUsers.length > 0 && ( +
+ +
+ )} +
+
+
+ {app.description} +
+
+
+
+
+ {app.author_name && ( + <> +
{app.author_name}
+
·
+ + )} +
{editTimeText}
+
+
+ + ) + + return ( +
+ {isPreviewOnly ? ( + + ) : ( + + {appCardContent} + + )} + - )} - {showAccessControl && ( - setShowAccessControl(false)} - /> - )} - - ) -} + {!isPreviewOnly && ( + + )} +
+ +
+
+ ) + }, +) diff --git a/web/app/components/apps/app-list-catalog.tsx b/web/app/components/apps/app-list-catalog.tsx new file mode 100644 index 00000000000..48ab3d418a6 --- /dev/null +++ b/web/app/components/apps/app-list-catalog.tsx @@ -0,0 +1,329 @@ +'use client' + +import type { + AppPagination, + AppPartial, + GetAppsData, +} from '@dify/contracts/api/console/apps/types.gen' +import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen' +import type { RefObject } from 'react' +import type { App } from '@/models/explore' +import type { TryAppSelection } from '@/types/try-app' +import { cn } from '@langgenius/dify-ui/cn' +import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { consoleQuery } from '@/service/client' +import { AppModeEnum } from '@/types/app' +import { AppCard } from './app-card' +import { AppCardSkeleton } from './app-card-skeleton' +import { AppListInfiniteScrollSentinel } from './app-list-infinite-scroll-sentinel' +import { APP_LIST_GRID_CLASS_NAME } from './constants' +import Empty from './empty' +import FirstEmptyState from './first-empty-state' +import { useAppListTour } from './hooks/use-app-list-tour' +import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users' +import { StarredAppList } from './starred-app-list' + +const STARRED_APP_LIMIT = 100 +const STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT = 4 +const emptyStarredApps: AppPartial[] = [] + +type AppListQuery = NonNullable + +type AppListCatalogProps = Readonly<{ + appListQuery: AppListQuery + canCreateApp: boolean + dragging: boolean + hasActiveFilters: boolean + onCreateBlank: () => void + onCreateLearnDify?: (app: App) => void + onCreateTemplate: () => void + onImportDSL: () => void + onOpenTagManagement: () => void + onTryLearnDify?: (params: TryAppSelection) => void + scrollViewportRef: RefObject +}> + +type AppListCatalogContentProps = Omit & + Readonly<{ + appListPages: AppPagination[] + hasNextPage: boolean + isFetchNextPageError: boolean + isFetching: boolean + isFetchingNextPage: boolean + isPlaceholderData: boolean + onFetchNextPage: () => Promise + starredApps: AppPartial[] + systemFeatures: GetSystemFeaturesResponse + }> + +function CatalogSkeleton() { + const { t } = useTranslation() + + return ( +
$.loading, { ns: 'common' })} + > + +
+ ) +} + +function AppListCatalogContent({ + appListPages, + canCreateApp, + dragging, + hasActiveFilters, + hasNextPage, + isFetchNextPageError, + isFetching, + isFetchingNextPage, + isPlaceholderData, + onCreateBlank, + onCreateLearnDify, + onCreateTemplate, + onFetchNextPage, + onImportDSL, + onOpenTagManagement, + onTryLearnDify, + starredApps, + scrollViewportRef, + systemFeatures, +}: AppListCatalogContentProps) { + const { t } = useTranslation() + + const apps = useMemo(() => appListPages.flatMap(({ data: pageApps }) => pageApps), [appListPages]) + const workflowOnlineUserAppIds = useMemo(() => { + const appIds = new Set() + apps.forEach((app) => { + if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) + appIds.add(app.id) + }) + return Array.from(appIds) + }, [apps]) + + const { onlineUsersMap: workflowOnlineUsersMap } = useWorkflowOnlineUsers({ + appIds: workflowOnlineUserAppIds, + enabled: systemFeatures.enable_collaboration_mode, + }) + + const hasResolvedFirstPage = appListPages.length > 0 + const hasAnyApp = (appListPages[0]?.total ?? 0) > 0 + const showFirstEmptyState = + !isPlaceholderData && !hasAnyApp && canCreateApp && hasResolvedFirstPage && !hasActiveFilters + const showNoCreateEmptyState = + !isPlaceholderData && !hasAnyApp && !canCreateApp && hasResolvedFirstPage && !hasActiveFilters + const { shouldHighlightAllAppsRow, shouldHighlightStarredAppRow, shouldOpenFirstAppActionMenu } = + useAppListTour({ + canCreateApp, + hasAnyApp, + hasResolvedFirstPage, + hasStarredApps: starredApps.length > 0, + showFirstEmptyState, + showNoCreateEmptyState, + }) + + return ( + <> + {showFirstEmptyState ? ( + + ) : ( + <> + {starredApps.length > 0 && ( + + )} +
+ {hasAnyApp ? ( + apps.map((app, index) => ( + + )) + ) : ( + + )} + {hasNextPage && ( +
+ +
+ + {isFetchNextPageError && !isFetchingNextPage && ( +
+ {t(($) => $['errorBoundary.title'], { ns: 'common' })} + +
+ )} +
+
+ )} +
+ + )} + + {canCreateApp && !showFirstEmptyState && ( +
$['newApp.dropDSLToCreateApp'], { ns: 'app' })} + > + + + {t(($) => $['newApp.dropDSLToCreateApp'], { ns: 'app' })} + +
+ )} + + ) +} + +export function AppListCatalog(props: AppListCatalogProps) { + const { + appListQuery, + canCreateApp, + dragging, + hasActiveFilters, + onCreateBlank, + onCreateLearnDify, + onCreateTemplate, + onImportDSL, + onOpenTagManagement, + onTryLearnDify, + scrollViewportRef, + } = props + const systemFeaturesQuery = useQuery(systemFeaturesQueryOptions()) + const systemFeatures = systemFeaturesQuery.data + const appList = useInfiniteQuery( + consoleQuery.apps.get.infiniteOptions({ + input: (pageParam) => ({ + query: { + ...appListQuery, + page: Number(pageParam), + }, + }), + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), + initialPageParam: 1, + placeholderData: keepPreviousData, + refetchInterval: systemFeatures?.enable_collaboration_mode ? 10000 : false, + }), + ) + const starredAppList = useQuery( + consoleQuery.apps.starred.get.queryOptions({ + input: { + query: { + ...appListQuery, + page: 1, + limit: STARRED_APP_LIMIT, + }, + }, + placeholderData: keepPreviousData, + }), + ) + + if (systemFeatures === undefined && systemFeaturesQuery.error) throw systemFeaturesQuery.error + if (appList.data === undefined && appList.error) throw appList.error + + if ( + systemFeatures === undefined || + appList.data === undefined || + (starredAppList.data === undefined && !starredAppList.error) + ) + return + + return ( + appList.fetchNextPage({ cancelRefetch: false })} + onImportDSL={onImportDSL} + onOpenTagManagement={onOpenTagManagement} + onTryLearnDify={onTryLearnDify} + scrollViewportRef={scrollViewportRef} + starredApps={starredAppList.data?.data ?? emptyStarredApps} + systemFeatures={systemFeatures} + /> + ) +} diff --git a/web/app/components/apps/app-list-creation-modals.tsx b/web/app/components/apps/app-list-creation-modals.tsx index 33358f721de..d436c595a84 100644 --- a/web/app/components/apps/app-list-creation-modals.tsx +++ b/web/app/components/apps/app-list-creation-modals.tsx @@ -1,8 +1,12 @@ 'use client' -import type { AppListCategory } from './app-type-filter-shared' +import type { AppListUrlQuery } from './query-params' +import { zPostAppsBody } from '@dify/contracts/api/console/apps/zod.gen' +import { useProviderContext } from '@/context/provider-context' import dynamic from '@/next/dynamic' +type AppListCategory = AppListUrlQuery['category'] + const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), { ssr: false, }) @@ -13,80 +17,60 @@ const CreateAppTemplateDialog = dynamic(() => import('@/app/components/app/creat ssr: false, }) +export type AppListCreationDialog = + | { type: 'blank' } + | { type: 'template' } + | { type: 'dsl'; droppedFile?: File } + | null + export function AppListCreationModals({ canCreateApp, category, - droppedDSLFile, - showCreateFromDSLModal, - showNewAppModal, - showNewAppTemplateDialog, - onPlanInfoChanged, - onRefetch, - onSetDroppedDSLFile, - onSetShowCreateFromDSLModal, - onSetShowNewAppModal, - onSetShowNewAppTemplateDialog, + dialog, + onClose, + onOpenBlank, + onOpenTemplate, }: { canCreateApp: boolean category: AppListCategory - droppedDSLFile?: File - showCreateFromDSLModal: boolean - showNewAppModal: boolean - showNewAppTemplateDialog: boolean - onPlanInfoChanged: () => void - onRefetch: () => void - onSetDroppedDSLFile: (file?: File) => void - onSetShowCreateFromDSLModal: (show: boolean) => void - onSetShowNewAppModal: (show: boolean) => void - onSetShowNewAppTemplateDialog: (show: boolean) => void + dialog: AppListCreationDialog + onClose: () => void + onOpenBlank: () => void + onOpenTemplate: () => void }) { + const { onPlanInfoChanged } = useProviderContext() + if (!canCreateApp) return null + const defaultAppModeResult = zPostAppsBody.shape.mode.safeParse(category) return ( <> - {showCreateFromDSLModal && ( + {dialog?.type === 'dsl' && ( { - onSetShowCreateFromDSLModal(false) - onSetDroppedDSLFile(undefined) - }} + show + onClose={onClose} onSuccess={() => { - onSetShowCreateFromDSLModal(false) - onSetDroppedDSLFile(undefined) + onClose() onPlanInfoChanged() - onRefetch() }} - droppedFile={droppedDSLFile} + droppedFile={dialog.droppedFile} /> )} - {showNewAppModal && ( + {dialog?.type === 'blank' && ( onSetShowNewAppModal(false)} - onSuccess={() => { - onPlanInfoChanged() - onRefetch() - }} - onCreateFromTemplate={() => { - onSetShowNewAppTemplateDialog(true) - onSetShowNewAppModal(false) - }} - defaultAppMode={category !== 'all' ? category : undefined} + show + onClose={onClose} + onSuccess={onPlanInfoChanged} + onCreateFromTemplate={onOpenTemplate} + defaultAppMode={defaultAppModeResult.success ? defaultAppModeResult.data : undefined} /> )} - {showNewAppTemplateDialog && ( + {dialog?.type === 'template' && ( onSetShowNewAppTemplateDialog(false)} - onSuccess={() => { - onPlanInfoChanged() - onRefetch() - }} - onCreateFromBlank={() => { - onSetShowNewAppModal(true) - onSetShowNewAppTemplateDialog(false) - }} + show + onClose={onClose} + onSuccess={onPlanInfoChanged} + onCreateFromBlank={onOpenBlank} /> )} diff --git a/web/app/components/apps/app-list-header-filters.tsx b/web/app/components/apps/app-list-header-filters.tsx deleted file mode 100644 index 2dc1b3b9f78..00000000000 --- a/web/app/components/apps/app-list-header-filters.tsx +++ /dev/null @@ -1,101 +0,0 @@ -'use client' - -import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen' -import type { AppListCategory } from './app-type-filter-shared' -import { useTranslation } from 'react-i18next' -import { CreateAppDropdown } from '@/app/components/app/create-app-dropdown' -import { SearchInput } from '@/app/components/base/search-input' -import { TagFilter } from '@/features/tag-management/components/tag-filter' -import Link from '@/next/link' -import { AppSortFilter } from './app-sort-filter' -import { AppTypeFilter } from './app-type-filter' -import CreatorsFilter from './creators-filter' - -type AppListQuery = NonNullable -type AppListSortBy = NonNullable - -type AppListHeaderFiltersProps = { - category: AppListCategory - tagIDs: string[] - keywords: string - creatorIDs: string[] - sortBy: AppListSortBy - onCategoryChange: (category: AppListCategory) => void - onTagIDsChange: (tagIDs: string[]) => void - onKeywordsChange: (keywords: string) => void - onCreatorIDsChange: (creatorIDs: string[]) => void - onSortByChange: (sortBy: AppListSortBy) => void - onCreateBlank: () => void - onCreateTemplate: () => void - onImportDSL: () => void - onOpenTagManagement: () => void - showCreateButton: boolean - stepByStepTourCreateMenuOpen?: boolean - stepByStepTourCreateMenuTarget?: string - stepByStepTourCreateMenuHighlightPart?: string -} - -export function AppListHeaderFilters({ - category, - tagIDs, - keywords, - creatorIDs, - sortBy, - onCategoryChange, - onTagIDsChange, - onKeywordsChange, - onCreatorIDsChange, - onSortByChange, - onCreateBlank, - onCreateTemplate, - onImportDSL, - onOpenTagManagement, - showCreateButton, - stepByStepTourCreateMenuOpen, - stepByStepTourCreateMenuTarget, - stepByStepTourCreateMenuHighlightPart, -}: AppListHeaderFiltersProps) { - const { t } = useTranslation() - return ( -
-
- - - - - $['gotoAnything.actions.searchApplications'], { ns: 'app' })} - /> -
-
- - - {t(($) => $['studio.viewSnippets'], { ns: 'app' })} - - {showCreateButton && ( - - )} -
-
- ) -} diff --git a/web/app/components/apps/app-list-header.tsx b/web/app/components/apps/app-list-header.tsx new file mode 100644 index 00000000000..afff219ed27 --- /dev/null +++ b/web/app/components/apps/app-list-header.tsx @@ -0,0 +1,132 @@ +'use client' + +import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen' +import type { AppListUrlQuery } from './query-params' +import { useAtomValue } from 'jotai' +import { useTranslation } from 'react-i18next' +import { CreateAppDropdown } from '@/app/components/app/create-app-dropdown' +import { SearchInput } from '@/app/components/base/search-input' +import { + activeStepByStepTourGuideGroupAtom, + activeStepByStepTourGuideIndexAtom, + activeStepByStepTourTaskIdAtom, +} from '@/app/components/step-by-step-tour/state' +import { + getStepByStepTourGuides, + STEP_BY_STEP_TOUR_TARGETS, +} from '@/app/components/step-by-step-tour/target-registry' +import { TagFilter } from '@/features/tag-management/components/tag-filter' +import Link from '@/next/link' +import { AppSortFilter } from './app-sort-filter' +import { AppTypeFilter } from './app-type-filter' +import CreatorsFilter from './creators-filter' +import { StudioListHeader } from './studio-list-header' + +type AppListQuery = NonNullable +type AppListCategory = AppListUrlQuery['category'] +type AppListSortBy = NonNullable + +type AppListHeaderProps = { + titleId: string + category: AppListCategory + tagIDs: string[] + keywords: string + creatorIDs: string[] + sortBy: AppListSortBy + onCategoryChange: (category: AppListCategory) => void + onTagIDsChange: (tagIDs: string[]) => void + onKeywordsChange: (keywords: string) => void + onCreatorIDsChange: (creatorIDs: string[]) => void + onSortByChange: (sortBy: AppListSortBy) => void + onCreateBlank: () => void + onCreateTemplate: () => void + onImportDSL: () => void + onOpenTagManagement: () => void + showCreateButton: boolean +} + +export function AppListHeader({ + titleId, + category, + tagIDs, + keywords, + creatorIDs, + sortBy, + onCategoryChange, + onTagIDsChange, + onKeywordsChange, + onCreatorIDsChange, + onSortByChange, + onCreateBlank, + onCreateTemplate, + onImportDSL, + onOpenTagManagement, + showCreateButton, +}: AppListHeaderProps) { + const { t } = useTranslation() + const activeStepByStepTourTaskId = useAtomValue(activeStepByStepTourTaskIdAtom) + const activeStepByStepTourGuideIndex = useAtomValue(activeStepByStepTourGuideIndexAtom) + const activeStepByStepTourGuideGroup = useAtomValue(activeStepByStepTourGuideGroupAtom) + const activeStudioGuides = + activeStepByStepTourTaskId === 'studio' && activeStepByStepTourGuideGroup + ? getStepByStepTourGuides('studio', activeStepByStepTourGuideGroup) + : [] + const activeStudioGuide = activeStudioGuides[activeStepByStepTourGuideIndex ?? 0] + const shouldOpenStepByStepTourCreateMenu = + activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate + + return ( + +

+ {t(($) => $['menus.apps'], { ns: 'common' })} +

+
+ } + > +
+
+ + + + + $['gotoAnything.actions.searchApplications'], { ns: 'app' })} + /> +
+
+ + + {t(($) => $['studio.viewSnippets'], { ns: 'app' })} + + {showCreateButton && ( + + )} +
+
+ + ) +} diff --git a/web/app/components/apps/app-list-infinite-scroll-sentinel.tsx b/web/app/components/apps/app-list-infinite-scroll-sentinel.tsx new file mode 100644 index 00000000000..7bb30ac8867 --- /dev/null +++ b/web/app/components/apps/app-list-infinite-scroll-sentinel.tsx @@ -0,0 +1,45 @@ +'use client' + +import type { RefObject } from 'react' +import { useEffect, useEffectEvent, useRef } from 'react' + +type AppListInfiniteScrollSentinelProps = { + canLoadMore: boolean + fetchNextPage: () => Promise + scrollViewportRef: RefObject +} + +export function AppListInfiniteScrollSentinel({ + canLoadMore, + fetchNextPage, + scrollViewportRef, +}: AppListInfiniteScrollSentinelProps) { + const sentinelRef = useRef(null) + const handleIntersection = useEffectEvent((entry: IntersectionObserverEntry) => { + if (entry.isIntersecting && canLoadMore) void fetchNextPage() + }) + + useEffect(() => { + const scrollRoot = scrollViewportRef.current + const sentinel = sentinelRef.current + if (!canLoadMore || !scrollRoot || !sentinel || typeof IntersectionObserver === 'undefined') + return + + const preloadDistance = Math.max(160, Math.min(scrollRoot.clientHeight * 0.25, 320)) + const observer = new IntersectionObserver( + ([entry]) => { + if (entry) handleIntersection(entry) + }, + { + root: scrollRoot, + rootMargin: `0px 0px ${preloadDistance}px 0px`, + threshold: 0, + }, + ) + + observer.observe(sentinel) + return () => observer.disconnect() + }, [canLoadMore, scrollViewportRef]) + + return
+} diff --git a/web/app/components/apps/app-list-tag-management-modal.tsx b/web/app/components/apps/app-list-tag-management-modal.tsx index a1fc1be9275..53a1bc61ed9 100644 --- a/web/app/components/apps/app-list-tag-management-modal.tsx +++ b/web/app/components/apps/app-list-tag-management-modal.tsx @@ -15,11 +15,9 @@ const TagManagementModal = dynamic( export function AppListTagManagementModal({ show, onClose, - onTagsChange, }: { show: boolean onClose: () => void - onTagsChange: () => unknown }) { - return + return } diff --git a/web/app/components/apps/app-type-filter-shared.ts b/web/app/components/apps/app-type-filter-shared.ts deleted file mode 100644 index 210c8ac8a48..00000000000 --- a/web/app/components/apps/app-type-filter-shared.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { parseAsStringLiteral } from 'nuqs' -import { AppModes } from '@/types/app' - -const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const -export type AppListCategory = (typeof APP_LIST_CATEGORY_VALUES)[number] - -export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES) - .withDefault('all') - .withOptions({ history: 'push' }) diff --git a/web/app/components/apps/app-type-filter.tsx b/web/app/components/apps/app-type-filter.tsx index 578bc7110c5..142714c2b00 100644 --- a/web/app/components/apps/app-type-filter.tsx +++ b/web/app/components/apps/app-type-filter.tsx @@ -1,6 +1,6 @@ 'use client' -import type { AppListCategory } from './app-type-filter-shared' +import type { AppListUrlQuery } from './query-params' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, @@ -12,7 +12,9 @@ import { } from '@langgenius/dify-ui/dropdown-menu' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { AppModeEnum } from '@/types/app' +import { studioAppListCategories } from './query-params' + +type AppListCategory = AppListUrlQuery['category'] const chipClassName = 'flex h-8 items-center whitespace-nowrap rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid' @@ -25,42 +27,39 @@ type AppTypeFilterProps = { export function AppTypeFilter({ value, onChange }: AppTypeFilterProps) { const { t } = useTranslation() - const options = useMemo( - () => - [ - { - value: 'all', - text: t(($) => $['types.all'], { ns: 'app' }), - iconClassName: 'i-ri-apps-2-line', - }, - { - value: AppModeEnum.WORKFLOW, - text: t(($) => $['types.workflow'], { ns: 'app' }), - iconClassName: 'i-ri-exchange-2-line', - }, - { - value: AppModeEnum.ADVANCED_CHAT, - text: t(($) => $['types.advanced'], { ns: 'app' }), - iconClassName: 'i-ri-message-3-line', - }, - { - value: AppModeEnum.CHAT, - text: t(($) => $['types.chatbot'], { ns: 'app' }), - iconClassName: 'i-ri-message-3-line', - }, - { - value: AppModeEnum.AGENT_CHAT, - text: t(($) => $['types.agent'], { ns: 'app' }), - iconClassName: 'i-ri-robot-3-line', - }, - { - value: AppModeEnum.COMPLETION, - text: t(($) => $['newApp.completeApp'], { ns: 'app' }), - iconClassName: 'i-ri-file-4-line', - }, - ] satisfies Array<{ value: AppListCategory; text: string; iconClassName: string }>, - [t], - ) + const options = useMemo(() => { + const optionsByCategory = { + all: { + text: t(($) => $['types.all'], { ns: 'app' }), + iconClassName: 'i-ri-apps-2-line', + }, + workflow: { + text: t(($) => $['types.workflow'], { ns: 'app' }), + iconClassName: 'i-ri-exchange-2-line', + }, + 'advanced-chat': { + text: t(($) => $['types.advanced'], { ns: 'app' }), + iconClassName: 'i-ri-message-3-line', + }, + chat: { + text: t(($) => $['types.chatbot'], { ns: 'app' }), + iconClassName: 'i-ri-message-3-line', + }, + 'agent-chat': { + text: t(($) => $['types.agent'], { ns: 'app' }), + iconClassName: 'i-ri-robot-3-line', + }, + completion: { + text: t(($) => $['newApp.completeApp'], { ns: 'app' }), + iconClassName: 'i-ri-file-4-line', + }, + } satisfies Record + + return studioAppListCategories.map((value) => ({ + value, + ...optionsByCategory[value], + })) + }, [t]) const activeOption = options.find((option) => option.value === value) const isSelected = value !== 'all' diff --git a/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx b/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx deleted file mode 100644 index bbbc9e549db..00000000000 --- a/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { act, waitFor } from '@testing-library/react' -import { renderHookWithNuqs } from '@/test/nuqs-testing' -import { AppModeEnum } from '@/types/app' -import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../../constants' -import { useAppsQueryState } from '../use-apps-query-state' - -const renderWithAdapter = (searchParams = '') => { - // oxlint-disable-next-line eslint-react/use-state -- renderHook executes a custom hook, not React.useState - return renderHookWithNuqs(() => useAppsQueryState(), { searchParams }) -} - -describe('useAppsQueryState', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should expose app list query state actions', () => { - const { result } = renderWithAdapter() - - expect(result.current.query).toEqual({ - category: 'all', - keywords: '', - creatorIDs: [], - }) - expect(typeof result.current.setCategory).toBe('function') - expect(typeof result.current.setKeywords).toBe('function') - expect(typeof result.current.setCreatorIDs).toBe('function') - }) - - it('should parse app list filters from URL', () => { - const { result } = renderWithAdapter('?category=workflow&tagIDs=tag1;tag2&keywords=search+term') - - expect(result.current.query).toEqual({ - category: AppModeEnum.WORKFLOW, - keywords: 'search term', - creatorIDs: [], - }) - }) - - it('should update category URL state', async () => { - const { result, onUrlUpdate } = renderWithAdapter() - - act(() => { - result.current.setCategory(AppModeEnum.WORKFLOW) - }) - - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls.at(-1)![0] - expect(result.current.query.category).toBe(AppModeEnum.WORKFLOW) - expect(update.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW) - expect(update.options.history).toBe('push') - }) - - it('should remove category from URL when set to all', async () => { - const { result, onUrlUpdate } = renderWithAdapter('?category=workflow') - - act(() => { - result.current.setCategory('all') - }) - - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls.at(-1)![0] - expect(result.current.query.category).toBe('all') - expect(update.searchParams.has('category')).toBe(false) - }) - - it('should update keywords state immediately while debouncing URL writes', async () => { - vi.useFakeTimers() - try { - const { result, onUrlUpdate } = renderWithAdapter() - - act(() => { - result.current.setKeywords('search') - }) - - expect(result.current.query.keywords).toBe('search') - expect(onUrlUpdate).not.toHaveBeenCalled() - - await act(async () => { - await vi.advanceTimersByTimeAsync(APP_LIST_SEARCH_DEBOUNCE_MS + 100) - }) - - expect(onUrlUpdate).toHaveBeenCalled() - const update = onUrlUpdate.mock.calls.at(-1)![0] - expect(update.searchParams.get('keywords')).toBe('search') - } finally { - vi.useRealTimers() - } - }) - - it('should remove keywords from URL when cleared', async () => { - vi.useFakeTimers() - try { - const { result, onUrlUpdate } = renderWithAdapter('?keywords=existing') - - act(() => { - result.current.setKeywords('') - }) - - expect(result.current.query.keywords).toBe('') - - await act(async () => { - await vi.advanceTimersByTimeAsync(APP_LIST_SEARCH_DEBOUNCE_MS + 100) - }) - - expect(onUrlUpdate).toHaveBeenCalled() - const update = onUrlUpdate.mock.calls.at(-1)![0] - expect(update.searchParams.has('keywords')).toBe(false) - } finally { - vi.useRealTimers() - } - }) - - it('should update creator IDs in local state without writing to the URL', () => { - const { result, onUrlUpdate } = renderWithAdapter() - - act(() => { - result.current.setCreatorIDs(['creator-1', 'creator-2']) - }) - - expect(result.current.query.creatorIDs).toEqual(['creator-1', 'creator-2']) - expect(onUrlUpdate).not.toHaveBeenCalled() - }) - - it('should clear creator IDs from local state without writing to the URL', () => { - const { result, onUrlUpdate } = renderWithAdapter() - - act(() => { - result.current.setCreatorIDs(['creator-1']) - }) - - act(() => { - result.current.setCreatorIDs([]) - }) - - expect(result.current.query.creatorIDs).toEqual([]) - expect(onUrlUpdate).not.toHaveBeenCalled() - }) -}) diff --git a/web/app/components/apps/hooks/__tests__/use-dsl-drag-drop.spec.ts b/web/app/components/apps/hooks/__tests__/use-dsl-drag-drop.spec.ts index 00e2d69ab24..8a8a534fceb 100644 --- a/web/app/components/apps/hooks/__tests__/use-dsl-drag-drop.spec.ts +++ b/web/app/components/apps/hooks/__tests__/use-dsl-drag-drop.spec.ts @@ -45,11 +45,11 @@ describe('useDSLDragDrop', () => { describe('Basic functionality', () => { it('should return dragging state', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } const { result } = renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -57,11 +57,11 @@ describe('useDSLDragDrop', () => { }) it('should initialize with dragging as false', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } const { result } = renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -71,11 +71,11 @@ describe('useDSLDragDrop', () => { describe('Drag events', () => { it('should set dragging to true on dragenter with files', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } const { result } = renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -90,11 +90,11 @@ describe('useDSLDragDrop', () => { }) it('should not set dragging on dragenter without files', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } const { result } = renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -108,11 +108,11 @@ describe('useDSLDragDrop', () => { }) it('should handle dragover event', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -127,11 +127,11 @@ describe('useDSLDragDrop', () => { }) it('should set dragging to false on dragleave when leaving container', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } const { result } = renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -154,47 +154,46 @@ describe('useDSLDragDrop', () => { expect(result.current.dragging).toBe(false) }) - it('should not set dragging to false on dragleave when within container', () => { - const containerRef = { current: container } - const childElement = document.createElement('div') - container.appendChild(childElement) + it('should keep dragging while moving from the viewport to its sibling overlay', () => { + const dropZoneRef = { current: container } + const viewport = document.createElement('div') + const overlay = document.createElement('div') + container.append(viewport, overlay) const { result } = renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')]) act(() => { - container.dispatchEvent(enterEvent) + viewport.dispatchEvent(enterEvent) }) expect(result.current.dragging).toBe(true) const leaveEvent = createDragEvent('dragleave') Object.defineProperty(leaveEvent, 'relatedTarget', { - value: childElement, + value: overlay, writable: false, }) act(() => { - container.dispatchEvent(leaveEvent) + viewport.dispatchEvent(leaveEvent) }) expect(result.current.dragging).toBe(true) - - container.removeChild(childElement) }) }) describe('Drop functionality', () => { it('should call onDSLFileDropped for .yaml file', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -209,11 +208,11 @@ describe('useDSLDragDrop', () => { }) it('should call onDSLFileDropped for .yml file', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -228,11 +227,11 @@ describe('useDSLDragDrop', () => { }) it('should call onDSLFileDropped for uppercase .YAML file', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -247,11 +246,11 @@ describe('useDSLDragDrop', () => { }) it('should not call onDSLFileDropped for non-yaml file', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -266,11 +265,11 @@ describe('useDSLDragDrop', () => { }) it('should set dragging to false on drop', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } const { result } = renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -289,11 +288,11 @@ describe('useDSLDragDrop', () => { }) it('should handle drop with no dataTransfer', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -319,11 +318,11 @@ describe('useDSLDragDrop', () => { }) it('should handle drop with empty files array', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -337,11 +336,11 @@ describe('useDSLDragDrop', () => { }) it('should only process the first file when multiple files are dropped', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -360,11 +359,11 @@ describe('useDSLDragDrop', () => { describe('Enabled prop', () => { it('should not add event listeners when enabled is false', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } const { result } = renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, enabled: false, }), ) @@ -380,12 +379,12 @@ describe('useDSLDragDrop', () => { }) it('should return dragging as false when enabled is false even if state is true', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } const { result, rerender } = renderHook( ({ enabled }) => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, enabled, }), { initialProps: { enabled: true } }, @@ -402,11 +401,11 @@ describe('useDSLDragDrop', () => { }) it('should default enabled to true', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } const { result } = renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -422,13 +421,13 @@ describe('useDSLDragDrop', () => { describe('Cleanup', () => { it('should remove event listeners on unmount', () => { - const containerRef = { current: container } + const dropZoneRef = { current: container } const removeEventListenerSpy = vi.spyOn(container, 'removeEventListener') const { unmount } = renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) @@ -444,28 +443,28 @@ describe('useDSLDragDrop', () => { }) describe('Edge cases', () => { - it('should handle null containerRef', () => { - const containerRef = { current: null } + it('should handle null dropZoneRef', () => { + const dropZoneRef = { current: null } const { result } = renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) expect(result.current.dragging).toBe(false) }) - it('should handle containerRef changing to null', () => { - const containerRef = { current: container as HTMLDivElement | null } + it('should handle dropZoneRef changing to null', () => { + const dropZoneRef = { current: container as HTMLDivElement | null } const { result, rerender } = renderHook(() => useDSLDragDrop({ onDSLFileDropped: mockOnDSLFileDropped, - containerRef, + dropZoneRef, }), ) - containerRef.current = null + dropZoneRef.current = null rerender() expect(result.current.dragging).toBe(false) diff --git a/web/app/components/apps/hooks/use-app-list-tour.ts b/web/app/components/apps/hooks/use-app-list-tour.ts new file mode 100644 index 00000000000..1b29bc2a1bf --- /dev/null +++ b/web/app/components/apps/hooks/use-app-list-tour.ts @@ -0,0 +1,74 @@ +import { useAtomValue, useSetAtom } from 'jotai' +import { useEffect } from 'react' +import { + activeStepByStepTourGuideGroupAtom, + activeStepByStepTourGuideIndexAtom, + activeStepByStepTourTaskIdAtom, + resolveStepByStepTourGuideGroupAtom, +} from '@/app/components/step-by-step-tour/state' +import { + getStepByStepTourGuides, + STEP_BY_STEP_TOUR_TARGETS, +} from '@/app/components/step-by-step-tour/target-registry' + +type UseAppListTourOptions = { + canCreateApp: boolean + hasAnyApp: boolean + hasResolvedFirstPage: boolean + hasStarredApps: boolean + showFirstEmptyState: boolean + showNoCreateEmptyState: boolean +} + +export function useAppListTour({ + canCreateApp, + hasAnyApp, + hasResolvedFirstPage, + hasStarredApps, + showFirstEmptyState, + showNoCreateEmptyState, +}: UseAppListTourOptions) { + const activeTaskId = useAtomValue(activeStepByStepTourTaskIdAtom) + const activeGuideIndex = useAtomValue(activeStepByStepTourGuideIndexAtom) + const resolvedGuideGroup = useAtomValue(activeStepByStepTourGuideGroupAtom) + const resolveGuideGroup = useSetAtom(resolveStepByStepTourGuideGroupAtom) + const derivedGuideGroup = canCreateApp + ? showFirstEmptyState + ? 'studioEmpty' + : hasAnyApp + ? 'studioWithApps' + : undefined + : hasAnyApp + ? 'studioNoCreateWithApps' + : showNoCreateEmptyState + ? 'studioNoCreateEmpty' + : undefined + const effectiveGuideGroup = resolvedGuideGroup ?? derivedGuideGroup + const guides = + activeTaskId === 'studio' && effectiveGuideGroup + ? getStepByStepTourGuides('studio', effectiveGuideGroup) + : [] + const activeGuide = guides[activeGuideIndex ?? 0] + const shouldOpenFirstAppActionMenu = + activeGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard + const shouldHighlightNoCreateAppRow = + activeGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard + const shouldHighlightStarredAppRow = shouldHighlightNoCreateAppRow && hasStarredApps + + useEffect(() => { + if (activeTaskId !== 'studio') return + if (!hasResolvedFirstPage || !derivedGuideGroup) return + if (resolvedGuideGroup === derivedGuideGroup) return + + resolveGuideGroup({ + taskId: 'studio', + guideGroup: derivedGuideGroup, + }) + }, [activeTaskId, derivedGuideGroup, hasResolvedFirstPage, resolveGuideGroup, resolvedGuideGroup]) + + return { + shouldHighlightAllAppsRow: shouldHighlightNoCreateAppRow && !shouldHighlightStarredAppRow, + shouldHighlightStarredAppRow, + shouldOpenFirstAppActionMenu, + } +} diff --git a/web/app/components/apps/hooks/use-apps-query-state.ts b/web/app/components/apps/hooks/use-apps-query-state.ts deleted file mode 100644 index 702155a1898..00000000000 --- a/web/app/components/apps/hooks/use-apps-query-state.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { AppListCategory } from '../app-type-filter-shared' -import { debounce, parseAsString, useQueryStates } from 'nuqs' -import { useCallback, useMemo, useState } from 'react' -import { parseAsAppListCategory } from '../app-type-filter-shared' -import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants' - -const appListQueryParsers = { - category: parseAsAppListCategory, - keywords: parseAsString.withDefault('').withOptions({ - limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS), - }), -} - -export function useAppsQueryState() { - const [urlQuery, setUrlQuery] = useQueryStates(appListQueryParsers) - const [creatorIDs, setCreatorIDs] = useState([]) - - const setCategory = useCallback( - (category: AppListCategory) => { - setUrlQuery({ category }) - }, - [setUrlQuery], - ) - - const setKeywords = useCallback( - (keywords: string) => { - setUrlQuery({ keywords }) - }, - [setUrlQuery], - ) - - const handleSetCreatorIDs = useCallback((creatorIDs: string[]) => { - setCreatorIDs(creatorIDs) - }, []) - - const query = useMemo( - () => ({ - ...urlQuery, - creatorIDs, - }), - [creatorIDs, urlQuery], - ) - - return useMemo( - () => ({ - query, - setCategory, - setKeywords, - setCreatorIDs: handleSetCreatorIDs, - }), - [handleSetCreatorIDs, query, setCategory, setKeywords], - ) -} diff --git a/web/app/components/apps/hooks/use-dsl-drag-drop.ts b/web/app/components/apps/hooks/use-dsl-drag-drop.ts index bfc53e4bea3..53198fc4eba 100644 --- a/web/app/components/apps/hooks/use-dsl-drag-drop.ts +++ b/web/app/components/apps/hooks/use-dsl-drag-drop.ts @@ -1,14 +1,14 @@ -import { useEffect, useState } from 'react' +import { useEffect, useEffectEvent, useState } from 'react' type DSLDragDropHookProps = { onDSLFileDropped: (file: File) => void - containerRef: React.RefObject + dropZoneRef: React.RefObject enabled?: boolean } export const useDSLDragDrop = ({ onDSLFileDropped, - containerRef, + dropZoneRef, enabled = true, }: DSLDragDropHookProps) => { const [dragging, setDragging] = useState(false) @@ -24,14 +24,14 @@ export const useDSLDragDrop = ({ e.stopPropagation() } - const handleDragLeave = (e: DragEvent) => { + const handleDragLeave = useEffectEvent((e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.relatedTarget === null || !containerRef.current?.contains(e.relatedTarget as Node)) + if (e.relatedTarget === null || !dropZoneRef.current?.contains(e.relatedTarget as Node)) setDragging(false) - } + }) - const handleDrop = (e: DragEvent) => { + const handleDrop = useEffectEvent((e: DragEvent) => { e.preventDefault() e.stopPropagation() setDragging(false) @@ -44,12 +44,12 @@ export const useDSLDragDrop = ({ const file = files[0] if (file!.name.toLowerCase().endsWith('.yaml') || file!.name.toLowerCase().endsWith('.yml')) onDSLFileDropped(file!) - } + }) useEffect(() => { if (!enabled) return - const current = containerRef.current + const current = dropZoneRef.current if (current) { current.addEventListener('dragenter', handleDragEnter) current.addEventListener('dragover', handleDragOver) @@ -64,7 +64,7 @@ export const useDSLDragDrop = ({ current.removeEventListener('drop', handleDrop) } } - }, [containerRef, enabled]) + }, [dropZoneRef, enabled]) return { dragging: enabled ? dragging : false, diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index 5f680f078b5..38cfbb71795 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -18,7 +18,7 @@ import { useRouter, useSearchParams } from '@/next/navigation' import { fetchAppDetail } from '@/service/explore' import { trackCreateApp } from '@/utils/create-app-tracking' import { hasPermission } from '@/utils/permission' -import List from './list' +import { List } from './list' const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false, @@ -29,6 +29,7 @@ const ImportFromMarketplaceTemplateModal = dynamic( () => import('./import-from-marketplace-template-modal'), { ssr: false }, ) +const AppListProvider = AppListContext.Provider const AppsContent = () => { const { t } = useTranslation() @@ -95,11 +96,6 @@ const AppsContent = () => { [], ) - const [controlRefreshList, setControlRefreshList] = useState(0) - const onSuccess = useCallback(() => { - setControlRefreshList((prev) => prev + 1) - }, []) - const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) const handleCloseTemplateModal = useCallback(() => { @@ -116,10 +112,9 @@ const AppsContent = () => { await handleImportDSLConfirm({ onSuccess: (response) => { trackCurrentCreateApp(response.app_mode) - onSuccess() }, }) - }, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp]) + }, [handleImportDSLConfirm, trackCurrentCreateApp]) const handleMarketplaceTemplateConfirm = useCallback( async (dslContent: string) => { @@ -139,7 +134,6 @@ const AppsContent = () => { onSuccess: (response) => { trackCurrentCreateApp(response.app_mode) handleCloseTemplateModal() - onSuccess() }, onPending: () => { handleCloseTemplateModal() @@ -148,14 +142,7 @@ const AppsContent = () => { }, ) }, - [ - canCreateApp, - handleImportDSL, - handleCloseTemplateModal, - onSuccess, - templateId, - trackCurrentCreateApp, - ], + [canCreateApp, handleImportDSL, handleCloseTemplateModal, templateId, trackCurrentCreateApp], ) const onCreate: CreateAppModalProps['onConfirm'] = useCallback( @@ -191,17 +178,13 @@ const AppsContent = () => { return ( <> - -
- +
+ {isShowTryAppPanel && currentTryAppParams && ( { /> )}
- + ) } -const Apps = () => ( - - - -) - -export default Apps +export function Apps() { + return ( + + + + ) +} diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 31ec8ba35b9..781c428987e 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -1,106 +1,75 @@ 'use client' import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen' +import type { AppListCreationDialog } from './app-list-creation-modals' +import type { AppListUrlQuery } from './query-params' import type { App } from '@/models/explore' import type { TryAppSelection } from '@/types/try-app' -import { cn } from '@langgenius/dify-ui/cn' import { - keepPreviousData, - useInfiniteQuery, - useQuery, - useSuspenseQuery, -} from '@tanstack/react-query' + ScrollArea, + ScrollAreaContent, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '@langgenius/dify-ui/scroll-area' import { useDebounce } from 'ahooks' -import { useAtomValue, useSetAtom } from 'jotai' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useNeedRefreshAppList } from '@/app/components/apps/storage' -import { - activeStepByStepTourGuideGroupAtom, - activeStepByStepTourGuideIndexAtom, - activeStepByStepTourTaskIdAtom, - resolveStepByStepTourGuideGroupAtom, -} from '@/app/components/step-by-step-tour/state' -import { - getStepByStepTourGuides, - STEP_BY_STEP_TOUR_TARGETS, -} from '@/app/components/step-by-step-tour/target-registry' +import { useAtomValue } from 'jotai' +import { useQueryStates } from 'nuqs' +import { useCallback, useId, useMemo, useRef, useState } from 'react' import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { useProviderContext } from '@/context/provider-context' -import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { CheckModal } from '@/hooks/use-pay' -import { consoleQuery } from '@/service/client' -import { normalizeAppPagination } from '@/service/use-apps' -import { AppModeEnum } from '@/types/app' import { hasPermission } from '@/utils/permission' -import { AppCard } from './app-card' -import { AppCardSkeleton } from './app-card-skeleton' +import { AppListCatalog } from './app-list-catalog' import { AppListCreationModals } from './app-list-creation-modals' -import { AppListHeaderFilters } from './app-list-header-filters' +import { AppListHeader } from './app-list-header' import { AppListTagManagementModal } from './app-list-tag-management-modal' -import { APP_LIST_GRID_CLASS_NAME, APP_LIST_SEARCH_DEBOUNCE_MS } from './constants' -import Empty from './empty' -import FirstEmptyState from './first-empty-state' -import { useAppsQueryState } from './hooks/use-apps-query-state' +import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' -import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users' -import { StarredAppList } from './starred-app-list' -import { StudioListHeader } from './studio-list-header' - -const STARRED_APP_LIMIT = 100 -const STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT = 4 +import { appListQueryParsers } from './query-params' type AppListQuery = NonNullable type AppListSortBy = NonNullable +type AppListCategory = AppListUrlQuery['category'] type Props = Readonly<{ - controlRefreshList?: number onCreateLearnDify?: (app: App) => void onTryLearnDify?: (params: TryAppSelection) => void }> -function List({ controlRefreshList = 0, onCreateLearnDify, onTryLearnDify }: Props) { - const { t } = useTranslation() - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) +export function List({ onCreateLearnDify, onTryLearnDify }: Props) { const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const { onPlanInfoChanged } = useProviderContext() - // oxlint-disable-next-line eslint-react/use-state -- custom URL query hook, not React.useState - const { - query: { category, keywords, creatorIDs }, - setCategory, - setKeywords, - setCreatorIDs, - } = useAppsQueryState() + const [urlQuery, setUrlQuery] = useQueryStates(appListQueryParsers) + const { category, keywords } = urlQuery + const [creatorIDs, setCreatorIDs] = useState([]) const [tagIDs, setTagIDs] = useState([]) const [sortBy, setSortBy] = useState('last_modified') const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS }) - const containerRef = useRef(null) + const dropZoneRef = useRef(null) + const scrollViewportRef = useRef(null) + const titleId = useId() const [showTagManagementModal, setShowTagManagementModal] = useState(false) - const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false) - const [showNewAppModal, setShowNewAppModal] = useState(false) - const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) - const [droppedDSLFile, setDroppedDSLFile] = useState() - const [needsRefreshAppList, setNeedsRefreshAppList] = useNeedRefreshAppList() - const activeStepByStepTourTaskId = useAtomValue(activeStepByStepTourTaskIdAtom) - const activeStepByStepTourGuideIndex = useAtomValue(activeStepByStepTourGuideIndexAtom) - const activeStepByStepTourGuideGroup = useAtomValue(activeStepByStepTourGuideGroupAtom) - const resolveStepByStepTourGuideGroup = useSetAtom(resolveStepByStepTourGuideGroupAtom) + const [creationDialog, setCreationDialog] = useState(null) const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') + const hasActiveFilters = + category !== 'all' || + tagIDs.length > 0 || + keywords.trim().length > 0 || + debouncedKeywords.trim().length > 0 || + creatorIDs.length > 0 const handleDSLFileDropped = useCallback( (file: File) => { if (!canCreateApp) return - setDroppedDSLFile(file) - setShowCreateFromDSLModal(true) + setCreationDialog({ type: 'dsl', droppedFile: file }) }, [canCreateApp], ) const { dragging } = useDSLDragDrop({ onDSLFileDropped: handleDSLFileDropped, - containerRef, + dropZoneRef, enabled: canCreateApp, }) @@ -117,355 +86,119 @@ function List({ controlRefreshList = 0, onCreateLearnDify, onTryLearnDify }: Pro [category, creatorIDs, debouncedKeywords, sortBy, tagIDs], ) - const { - data, - isLoading, - isFetching, - isFetchingNextPage, - fetchNextPage, - hasNextPage, - error, - refetch, - } = useInfiniteQuery({ - ...consoleQuery.apps.get.infiniteOptions({ - input: (pageParam) => ({ - query: { - ...appListQuery, - page: Number(pageParam), - }, - }), - getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), - initialPageParam: 1, - placeholderData: keepPreviousData, - }), - select: (data) => ({ - ...data, - pages: data.pages.map(normalizeAppPagination), - }), - refetchInterval: systemFeatures.enable_collaboration_mode ? 10000 : false, - }) + const resetCatalogScroll = () => { + scrollViewportRef.current?.scrollTo({ top: 0 }) + } + const changeCategory = (nextCategory: AppListCategory) => { + resetCatalogScroll() + void setUrlQuery({ category: nextCategory }) + } + const changeTagIDs = (nextTagIDs: string[]) => { + resetCatalogScroll() + setTagIDs(nextTagIDs) + } + const changeKeywords = (nextKeywords: string) => { + resetCatalogScroll() + void setUrlQuery({ keywords: nextKeywords }) + } + const changeCreatorIDs = (nextCreatorIDs: string[]) => { + resetCatalogScroll() + setCreatorIDs(nextCreatorIDs) + } + const changeSortBy = (nextSortBy: AppListSortBy) => { + resetCatalogScroll() + setSortBy(nextSortBy) + } - const starredAppListQuery = useMemo( - () => ({ - ...appListQuery, - page: 1, - limit: STARRED_APP_LIMIT, - }), - [appListQuery], - ) - - const { data: starredAppList, refetch: refetchStarredAppList } = useQuery({ - ...consoleQuery.apps.starred.get.queryOptions({ - input: { - query: starredAppListQuery, - }, - select: normalizeAppPagination, - }), - }) - - const refreshAppLists = useCallback(() => { - void refetch() - void refetchStarredAppList() - }, [refetch, refetchStarredAppList]) - - useEffect(() => { - if (controlRefreshList > 0) refetch() - }, [controlRefreshList, refetch]) - - const anchorRef = useRef(null) - - useEffect(() => { - if (needsRefreshAppList === '1') { - setNeedsRefreshAppList(null) - refetch() - } - }, [needsRefreshAppList, refetch, setNeedsRefreshAppList]) - - useEffect(() => { - const hasMore = hasNextPage ?? true - let observer: IntersectionObserver | undefined - - if (error) { - if (observer) observer.disconnect() - return - } - - if (anchorRef.current && containerRef.current) { - const containerHeight = containerRef.current.clientHeight - const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) - - observer = new IntersectionObserver( - (entries) => { - if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore) - fetchNextPage() - }, - { - root: containerRef.current, - rootMargin: `${dynamicMargin}px`, - threshold: 0.1, - }, - ) - observer.observe(anchorRef.current) - } - return () => observer?.disconnect() - }, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage]) - - const pages = useMemo(() => data?.pages ?? [], [data?.pages]) - const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages]) - const starredApps = useMemo(() => starredAppList?.data ?? [], [starredAppList?.data]) - - const workflowOnlineUserAppIds = useMemo(() => { - const appIds = new Set() - apps.forEach((app) => { - if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) - appIds.add(app.id) - }) - return Array.from(appIds) - }, [apps]) - - const { onlineUsersMap: workflowOnlineUsersMap } = useWorkflowOnlineUsers({ - appIds: workflowOnlineUserAppIds, - enabled: systemFeatures.enable_collaboration_mode, - }) - - const hasResolvedFirstPage = pages.length > 0 - const hasAnyApp = (pages[0]?.total ?? 0) > 0 - const hasActiveFilters = - category !== 'all' || - tagIDs.length > 0 || - keywords.trim().length > 0 || - debouncedKeywords.trim().length > 0 || - creatorIDs.length > 0 - const showSkeleton = isLoading || (isFetching && pages.length === 0) - const showFirstEmptyState = - !showSkeleton && !hasAnyApp && canCreateApp && hasResolvedFirstPage && !hasActiveFilters - const showNoCreateEmptyState = - !showSkeleton && !hasAnyApp && !canCreateApp && hasResolvedFirstPage && !hasActiveFilters - const activeStudioGuideGroup = canCreateApp - ? showFirstEmptyState - ? 'studioEmpty' - : hasAnyApp - ? 'studioWithApps' - : undefined - : hasAnyApp - ? 'studioNoCreateWithApps' - : showNoCreateEmptyState - ? 'studioNoCreateEmpty' - : undefined - const effectiveActiveStudioGuideGroup = activeStepByStepTourGuideGroup ?? activeStudioGuideGroup - const activeStudioGuides = - activeStepByStepTourTaskId === 'studio' && effectiveActiveStudioGuideGroup - ? getStepByStepTourGuides('studio', effectiveActiveStudioGuideGroup) - : [] - const activeStudioGuide = activeStudioGuides[activeStepByStepTourGuideIndex ?? 0] - const shouldOpenStepByStepTourCreateMenu = - activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate - const shouldOpenStepByStepTourAppCardActionMenu = - activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard - const shouldHighlightStepByStepTourNoCreateAppRow = - activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard - const shouldHighlightStepByStepTourStarredAppRow = - shouldHighlightStepByStepTourNoCreateAppRow && starredApps.length > 0 - const shouldHighlightStepByStepTourAllAppsRow = - shouldHighlightStepByStepTourNoCreateAppRow && !shouldHighlightStepByStepTourStarredAppRow const openCreateBlankModal = useCallback(() => { - if (canCreateApp) setShowNewAppModal(true) + if (canCreateApp) setCreationDialog({ type: 'blank' }) }, [canCreateApp]) const openCreateTemplateDialog = useCallback(() => { - if (canCreateApp) setShowNewAppTemplateDialog(true) + if (canCreateApp) setCreationDialog({ type: 'template' }) }, [canCreateApp]) const openCreateFromDSLModal = useCallback(() => { - if (canCreateApp) setShowCreateFromDSLModal(true) + if (canCreateApp) setCreationDialog({ type: 'dsl' }) }, [canCreateApp]) - - useEffect(() => { - if (activeStepByStepTourTaskId !== 'studio') return - if (!hasResolvedFirstPage || showSkeleton || !activeStudioGuideGroup) return - if (activeStepByStepTourGuideGroup === activeStudioGuideGroup) return - - resolveStepByStepTourGuideGroup({ - taskId: 'studio', - guideGroup: activeStudioGuideGroup, - }) - }, [ - activeStepByStepTourGuideGroup, - activeStepByStepTourTaskId, - activeStudioGuideGroup, - hasResolvedFirstPage, - resolveStepByStepTourGuideGroup, - showSkeleton, - ]) + const openTagManagement = useCallback(() => setShowTagManagementModal(true), []) return ( <>
{dragging && (
)} - -

- {t(($) => $['menus.apps'], { ns: 'common' })} -

-
- } - > - setShowTagManagementModal(true)} - showCreateButton={canCreateApp} - stepByStepTourCreateMenuOpen={ - activeStudioGuide ? shouldOpenStepByStepTourCreateMenu : undefined - } - stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate} - stepByStepTourCreateMenuHighlightPart={ - STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu - } - /> - - {showFirstEmptyState ? ( - - ) : ( - <> - {starredApps.length > 0 && ( - - )} -
- {showSkeleton ? ( - - ) : hasAnyApp ? ( - apps.map((app, index) => ( - setShowTagManagementModal(true)} - stepByStepTourActionMenuOpen={ - index === 0 ? shouldOpenStepByStepTourAppCardActionMenu : undefined - } - stepByStepTourCardTarget={ - index === 0 - ? shouldHighlightStepByStepTourAllAppsRow - ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard - : canCreateApp - ? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard - : undefined - : undefined - } - stepByStepTourCardHighlightPart={ - index < STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT && - shouldHighlightStepByStepTourAllAppsRow - ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard - : undefined - } - stepByStepTourActionMenuHighlightPart={ - index === 0 && shouldOpenStepByStepTourAppCardActionMenu - ? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu - : undefined - } - /> - )) - ) : ( - - )} - {isFetchingNextPage && } -
- - )} + - {canCreateApp && !showFirstEmptyState && ( -
$['newApp.dropDSLToCreateApp'], { ns: 'app' })} - > - - - {t(($) => $['newApp.dropDSLToCreateApp'], { ns: 'app' })} - -
- )} - -
- {' '} +
+ + + + + + + + + +
+ + setShowTagManagementModal(false)} - onTagsChange={refreshAppLists} />
setCreationDialog(null)} + onOpenBlank={openCreateBlankModal} + onOpenTemplate={openCreateTemplateDialog} /> ) } - -export default List diff --git a/web/app/components/apps/query-params.ts b/web/app/components/apps/query-params.ts new file mode 100644 index 00000000000..fd3864628bf --- /dev/null +++ b/web/app/components/apps/query-params.ts @@ -0,0 +1,40 @@ +import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen' +import type { inferParserType } from 'nuqs' +import { zGetAppsQuery } from '@dify/contracts/api/console/apps/zod.gen' +import { createParser, debounce, parseAsString } from 'nuqs' +import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants' + +type AppListMode = NonNullable['mode']> + +export const studioAppListCategories = [ + 'all', + 'workflow', + 'advanced-chat', + 'chat', + 'agent-chat', + 'completion', +] as const satisfies readonly AppListMode[] + +const studioAppListCategorySchema = zGetAppsQuery.shape.mode + .unwrap() + .unwrap() + .extract(studioAppListCategories) + +const parseAsAppListCategory = createParser({ + parse: (value) => { + const result = studioAppListCategorySchema.safeParse(value) + return result.success ? result.data : null + }, + serialize: String, +}) + .withDefault('all') + .withOptions({ history: 'push' }) + +export const appListQueryParsers = { + category: parseAsAppListCategory, + keywords: parseAsString.withDefault('').withOptions({ + limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS), + }), +} + +export type AppListUrlQuery = inferParserType diff --git a/web/app/components/apps/starred-app-card.tsx b/web/app/components/apps/starred-app-card.tsx index 86b9ae80c37..a04ec2ddd2f 100644 --- a/web/app/components/apps/starred-app-card.tsx +++ b/web/app/components/apps/starred-app-card.tsx @@ -1,12 +1,12 @@ 'use client' -import type { KeyboardEvent } from 'react' -import type { App } from '@/types/app' +import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen' +import { zIconType } from '@dify/contracts/api/console/apps/zod.gen' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' -import { useCallback, useMemo } from 'react' +import { memo, useCallback, useId, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { AppTypeIcon } from '@/app/components/app/type-selector' import AppIcon from '@/app/components/base/app-icon' @@ -20,112 +20,101 @@ import { formatTime } from '@/utils/time' import { AppCardActionBar } from './app-card' type StarredAppCardProps = { - app: App - onRefresh?: () => void + app: AppPartial stepByStepTourCardTarget?: string stepByStepTourCardHighlightPart?: string } -export function StarredAppCard({ - app, - onRefresh, - stepByStepTourCardTarget, - stepByStepTourCardHighlightPart, -}: StarredAppCardProps) { - const { t } = useTranslation() - const currentUserId = useAtomValue(userProfileIdAtom) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const isRbacEnabled = systemFeatures.rbac_enabled - const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) +export const StarredAppCard = memo( + ({ app, stepByStepTourCardTarget, stepByStepTourCardHighlightPart }: StarredAppCardProps) => { + const { t } = useTranslation() + const currentUserId = useAtomValue(userProfileIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const isRbacEnabled = systemFeatures.rbac_enabled + const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) + const appIconType = zIconType.safeParse(app.icon_type).data ?? null - const editTimeText = useMemo(() => { - const timestamp = app.updated_at || app.created_at - if (!timestamp) return '' + const editTimeText = useMemo(() => { + const timestamp = app.updated_at || app.created_at + if (!timestamp) return '' - const timeText = formatTime({ - date: timestamp * 1000, - dateFormat: `${t(($) => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, + const timeText = formatTime({ + date: timestamp * 1000, + dateFormat: `${t(($) => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, + }) + return `${t(($) => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}` + }, [app.created_at, app.updated_at, t]) + const href = getRedirectionPath(app, { + currentUserId, + resourceMaintainer: app.maintainer, + workspacePermissionKeys, + isRbacEnabled, }) - return `${t(($) => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}` - }, [app.created_at, app.updated_at, t]) - const href = getRedirectionPath(app, { - currentUserId, - resourceMaintainer: app.maintainer, - workspacePermissionKeys, - isRbacEnabled, - }) - const cardClassName = cn( - 'flex h-18 min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg px-4 py-3 shadow-xs outline-hidden transition-shadow duration-200', - isPreviewOnly - ? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid' - : 'hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid', - ) - const showPreviewOnlyAccessWarning = useCallback(() => { - toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) - }, [t]) - const handlePreviewOnlyCardKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.key !== 'Enter' && event.key !== ' ') return - - event.preventDefault() - showPreviewOnlyAccessWarning() - }, - [showPreviewOnlyAccessWarning], - ) - const cardContent = ( - <> -
- - -
-
-
{app.name}
-
- {app.author_name && {app.author_name}} - {app.author_name && editTimeText && ·} - {editTimeText && {editTimeText}} + const cardClassName = cn( + 'flex h-18 min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg px-4 py-3 shadow-xs outline-hidden transition-shadow duration-200', + isPreviewOnly + ? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid' + : 'hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid', + ) + const showPreviewOnlyAccessWarning = useCallback(() => { + toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) + }, [t]) + const appNameId = useId() + const cardContent = ( + <> +
+ +
-
- - ) - - return ( -
- {isPreviewOnly ? ( -
- {cardContent} +
+
+ {app.name} +
+
+ {app.author_name && {app.author_name}} + {app.author_name && editTimeText && ·} + {editTimeText && {editTimeText}} +
- ) : ( - - {cardContent} - - )} - {!isPreviewOnly && } -
- ) -} + + ) + + return ( +
+ {isPreviewOnly ? ( + + ) : ( + + {cardContent} + + )} + {!isPreviewOnly && } +
+ ) + }, +) diff --git a/web/app/components/apps/starred-app-list.tsx b/web/app/components/apps/starred-app-list.tsx index c3994f7af8c..ec3b9f5f74b 100644 --- a/web/app/components/apps/starred-app-list.tsx +++ b/web/app/components/apps/starred-app-list.tsx @@ -1,13 +1,12 @@ 'use client' -import type { App } from '@/types/app' +import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen' import { useTranslation } from 'react-i18next' import { APP_LIST_GRID_CLASS_NAME } from './constants' import { StarredAppCard } from './starred-app-card' type StarredAppListProps = { - apps: App[] - onRefresh?: () => void + apps: AppPartial[] stepByStepTourCardTarget?: string stepByStepTourCardHighlightPart?: string stepByStepTourHighlightedCardCount?: number @@ -25,7 +24,6 @@ function SectionDivider({ label }: { label: string }) { export function StarredAppList({ apps, - onRefresh, stepByStepTourCardTarget, stepByStepTourCardHighlightPart, stepByStepTourHighlightedCardCount = 0, @@ -42,7 +40,6 @@ export function StarredAppList({ (NEED_REFRESH_APP_LIST_KEY, '0', { raw: true }) - -export { useNeedRefreshAppList, useSetNeedRefreshAppList } diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx index db00810c07a..ebd7a8b5cf7 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx @@ -1,5 +1,6 @@ import type { Features } from '../../../types' import type { OnFeaturesChange } from '@/app/components/base/features/types' +import { skipToken } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { TtsAutoPlay } from '@/types/app' @@ -18,8 +19,21 @@ let mockVoiceItems: { value: string; name: string }[] | undefined = [ { value: 'echo', name: 'Echo' }, ] -const mockUseAppVoices = vi.fn((_appId: string, _language?: string) => ({ - data: mockVoiceItems, +const mockVoicesQuery = vi.fn( + (_options: { + enabled: boolean + input: typeof skipToken | { params: { app_id: string }; query: { language: string } } + }) => ({ + data: mockVoiceItems, + }), +) + +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useQuery: (options: { + enabled: boolean + input: typeof skipToken | { params: { app_id: string }; query: { language: string } } + }) => mockVoicesQuery(options), })) vi.mock('@/next/navigation', () => ({ @@ -33,10 +47,6 @@ vi.mock('@/i18n-config/language', () => ({ }, })) -vi.mock('@/service/use-apps', () => ({ - useAppVoices: (appId: string, language?: string) => mockUseAppVoices(appId, language), -})) - const defaultFeatures: Features = { moreLikeThis: { enabled: false }, opening: { enabled: false }, @@ -331,12 +341,17 @@ describe('ParamConfigContent', () => { expect(getVoiceSelect()).toHaveAttribute('data-disabled') }) - it('should call useAppVoices with empty appId when pathname has no app segment', () => { + it('should disable the voices query when pathname has no app segment', () => { mockPathname = '/configuration' renderWithProvider() - expect(mockUseAppVoices).toHaveBeenCalledWith('', 'en-US') + expect(mockVoicesQuery).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + input: skipToken, + }), + ) }) it('should render language text when selected language value is empty string', () => { diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx index 5101c5345f3..781f95af9b4 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx @@ -17,10 +17,9 @@ vi.mock('@/next/navigation', () => ({ useParams: () => ({ appId: 'test-app-id' }), })) -vi.mock('@/service/use-apps', () => ({ - useAppVoices: () => ({ - data: [{ name: 'alloy', value: 'alloy' }], - }), +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useQuery: () => ({ data: [{ name: 'alloy', value: 'alloy' }] }), })) const defaultFeatures: Features = { diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx index 5ce4d94f59b..8e3bc4c6457 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx @@ -10,6 +10,7 @@ import { SelectTrigger, } from '@langgenius/dify-ui/select' import { Switch } from '@langgenius/dify-ui/switch' +import { skipToken, useQuery } from '@tanstack/react-query' import { produce } from 'immer' import { useTranslation } from 'react-i18next' import { replace } from 'string-ts' @@ -18,7 +19,7 @@ import { useFeatures, useFeaturesStore } from '@/app/components/base/features/ho import { Infotip } from '@/app/components/base/infotip' import { languages } from '@/i18n-config/language' import { usePathname } from '@/next/navigation' -import { useAppVoices } from '@/service/use-apps' +import { consoleQuery } from '@/service/client' import { TtsAutoPlay } from '@/types/app' type SelectOption = { @@ -51,7 +52,16 @@ const VoiceParamConfig = ({ onClose, onChange }: VoiceParamConfigProps) => { languageItem?.name || t(($) => $['placeholder.select'], { ns: 'common' }) const language = languageItem?.value - const { data: voiceItems } = useAppVoices(appId, language) + const { data: voiceItems } = useQuery( + consoleQuery.apps.byAppId.textToAudio.voices.get.queryOptions({ + input: appId + ? { + params: { app_id: appId }, + query: { language: language || 'en-US' }, + } + : skipToken, + }), + ) let voiceItem = voiceItems?.find((item) => item.value === text2speech?.voice) if (voiceItems && !voiceItem) voiceItem = voiceItems[0] const localVoicePlaceholder = diff --git a/web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx index 35b1da89a31..b332defdbbe 100644 --- a/web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx @@ -1,12 +1,10 @@ -import type { CreateApiKeyResponse } from '@/models/app' +import type { ApiKeyItem } from '@dify/contracts/api/console/apps/types.gen' import { act, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import SecretKeyGenerateModal from '../secret-key-generate' -const createMockApiKey = (token: string): CreateApiKeyResponse => ({ - id: 'mock-id', +const createMockApiKey = (token: string): Pick => ({ token, - created_at: '2024-01-01T00:00:00Z', }) async function renderModal(ui: React.ReactElement) { @@ -51,8 +49,7 @@ describe('SecretKeyGenerateModal', () => { it('should render the close icon', async () => { await renderModal() - const closeIcon = document.body.querySelector('svg.cursor-pointer') - expect(closeIcon).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) it('should render InputCopy component', async () => { @@ -103,12 +100,7 @@ describe('SecretKeyGenerateModal', () => { const onClose = vi.fn() await renderModal() - const closeIcon = document.body.querySelector('svg.cursor-pointer') - expect(closeIcon).toBeInTheDocument() - - await act(async () => { - await user.click(closeIcon!) - }) + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(onClose).toHaveBeenCalled() }) @@ -135,34 +127,6 @@ describe('SecretKeyGenerateModal', () => { }) }) - describe('header section', () => { - it('should have flex justify-end on close container', async () => { - await renderModal() - const closeIcon = document.body.querySelector('svg.cursor-pointer') - const closeContainer = closeIcon?.parentElement - expect(closeContainer).toBeInTheDocument() - expect(closeContainer?.className).toContain('flex') - expect(closeContainer?.className).toContain('justify-end') - }) - - it('should have negative margin on close container', async () => { - await renderModal() - const closeIcon = document.body.querySelector('svg.cursor-pointer') - const closeContainer = closeIcon?.parentElement - expect(closeContainer).toBeInTheDocument() - expect(closeContainer?.className).toContain('-mr-2') - expect(closeContainer?.className).toContain('-mt-6') - }) - - it('should have bottom margin on close container', async () => { - await renderModal() - const closeIcon = document.body.querySelector('svg.cursor-pointer') - const closeContainer = closeIcon?.parentElement - expect(closeContainer).toBeInTheDocument() - expect(closeContainer?.className).toContain('mb-4') - }) - }) - describe('tips text styling', () => { it('should have mt-1 margin on tips', async () => { await renderModal() diff --git a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx index 121f1c7a19b..b7b75c7722e 100644 --- a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx @@ -1,736 +1,207 @@ -import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import type { ApiKeyList as AppApiKeyList } from '@dify/contracts/api/console/apps/types.gen' +import type { ApiKeyList as DatasetApiKeyList } from '@dify/contracts/api/console/datasets/types.gen' +import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach } from 'vitest' import { render } from '@/test/console/render' import SecretKeyModal from '../secret-key-modal' -async function renderModal(ui: React.ReactElement) { - const result = render(ui) - await act(async () => { - vi.runAllTimers() - }) - return result -} - -async function flushTransitions() { - await act(async () => { - vi.runAllTimers() - }) - await act(async () => { - vi.runAllTimers() - }) +type MutationCallbacks = { + onSuccess?: (data: TData) => void } const mockCurrentWorkspace = vi.fn().mockReturnValue({ id: 'workspace-1', name: 'Test Workspace', }) -const mockIsCurrentWorkspaceManager = vi.fn().mockReturnValue(true) -const mockIsCurrentWorkspaceEditor = vi.fn().mockReturnValue(true) vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') return createWorkspaceStateModuleMock(() => ({ currentWorkspace: mockCurrentWorkspace(), - isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(), - isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(), + isCurrentWorkspaceManager: true, + isCurrentWorkspaceEditor: true, })) }) vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ - formatTime: vi.fn((value: number, _format: string) => `Formatted: ${value}`), - formatDate: vi.fn((value: string, _format: string) => `Formatted: ${value}`), + formatTime: (value: number) => `Formatted: ${value}`, }), })) -const mockCreateAppApikey = vi.fn().mockResolvedValue({ token: 'new-app-token-123' }) -const mockDelAppApikey = vi.fn().mockResolvedValue({}) -vi.mock('@/service/apps', () => ({ - createApikey: (...args: unknown[]) => mockCreateAppApikey(...args), - delApikey: (...args: unknown[]) => mockDelAppApikey(...args), -})) +let appApiKeys: AppApiKeyList = { data: [] } +let datasetApiKeys: DatasetApiKeyList = { data: [] } +let appApiKeysLoading = false +let datasetApiKeysLoading = false -const mockCreateDatasetApikey = vi.fn().mockResolvedValue({ token: 'new-dataset-token-123' }) -const mockDelDatasetApikey = vi.fn().mockResolvedValue({}) -vi.mock('@/service/datasets', () => ({ - createApikey: (...args: unknown[]) => mockCreateDatasetApikey(...args), - delApikey: (...args: unknown[]) => mockDelDatasetApikey(...args), -})) +const createAppApiKey = vi.fn( + (_variables: unknown, callbacks?: MutationCallbacks<{ token: string }>) => + callbacks?.onSuccess?.({ token: 'new-app-token-123' }), +) +const deleteAppApiKey = vi.fn() +const createDatasetApiKey = vi.fn( + (_variables: unknown, callbacks?: MutationCallbacks<{ token: string }>) => + callbacks?.onSuccess?.({ token: 'new-dataset-token-123' }), +) +const deleteDatasetApiKey = vi.fn() -const mockAppApiKeysData = vi.fn().mockReturnValue({ data: [] }) -const mockIsAppApiKeysLoading = vi.fn().mockReturnValue(false) -const mockInvalidateAppApiKeys = vi.fn() +let mutationHookIndex = 0 +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useMutation: () => { + const mutations = [createAppApiKey, deleteAppApiKey, createDatasetApiKey, deleteDatasetApiKey] + const mutate = mutations[mutationHookIndex % mutations.length] + mutationHookIndex += 1 + return { mutate } + }, + useQuery: (options: { queryKey: readonly unknown[] }) => { + const isAppQuery = JSON.stringify(options.queryKey).includes('"apps"') + return isAppQuery + ? { data: appApiKeys, isLoading: appApiKeysLoading } + : { data: datasetApiKeys, isLoading: datasetApiKeysLoading } + }, + } +}) -vi.mock('@/service/use-apps', () => ({ - useAppApiKeys: (_appId: string, _options: unknown) => ({ - data: mockAppApiKeysData(), - isLoading: mockIsAppApiKeysLoading(), - }), - useInvalidateAppApiKeys: () => mockInvalidateAppApiKeys, -})) +async function renderModal(appId?: string) { + const onClose = vi.fn() + const result = render() + await act(async () => { + vi.runAllTimers() + }) + return { ...result, onClose } +} -const mockDatasetApiKeysData = vi.fn().mockReturnValue({ data: [] }) -const mockIsDatasetApiKeysLoading = vi.fn().mockReturnValue(false) -const mockInvalidateDatasetApiKeys = vi.fn() - -vi.mock('@/service/knowledge/use-dataset', () => ({ - useDatasetApiKeys: (_options: unknown) => ({ - data: mockDatasetApiKeysData(), - isLoading: mockIsDatasetApiKeysLoading(), - }), - useInvalidateDatasetApiKeys: () => mockInvalidateDatasetApiKeys, -})) +async function confirmFirstKeyDeletion() { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + const deleteButton = document.body.querySelectorAll('button.action-btn')[1] + expect(deleteButton).toBeInTheDocument() + await user.click(deleteButton!) + await act(async () => { + vi.runAllTimers() + }) + await user.click(await screen.findByText('common.operation.confirm')) +} describe('SecretKeyModal', () => { - const defaultProps = { - isShow: true, - canManage: true, - onClose: vi.fn(), - } - beforeEach(() => { vi.clearAllMocks() - // Suppress expected React act() warnings from modal transitions and async API state updates. - vi.spyOn(console, 'error').mockImplementation(() => {}) vi.useFakeTimers({ shouldAdvanceTime: true }) - mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' }) - mockIsCurrentWorkspaceManager.mockReturnValue(true) - mockIsCurrentWorkspaceEditor.mockReturnValue(true) - mockAppApiKeysData.mockReturnValue({ data: [] }) - mockIsAppApiKeysLoading.mockReturnValue(false) - mockDatasetApiKeysData.mockReturnValue({ data: [] }) - mockIsDatasetApiKeysLoading.mockReturnValue(false) + mutationHookIndex = 0 + appApiKeys = { data: [] } + datasetApiKeys = { data: [] } + appApiKeysLoading = false + datasetApiKeysLoading = false }) afterEach(() => { vi.runOnlyPendingTimers() vi.useRealTimers() - vi.restoreAllMocks() }) - describe('rendering when shown', () => { - it('should render the modal when isShow is true', async () => { - await renderModal() - expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() - }) - - it('should render the tips text', async () => { - await renderModal() - expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument() - }) - - it('should render the create new key button', async () => { - await renderModal() - expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument() - }) - - it('should render the close icon', async () => { - await renderModal() - const closeIcon = document.body.querySelector('.i-heroicons-x-mark-20-solid') - expect(closeIcon).toBeInTheDocument() - }) - }) - - describe('rendering when hidden', () => { - it('should not render content when isShow is false', async () => { - await renderModal() - expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument() - }) - }) - - describe('loading state', () => { - it('should show loading when app API keys are loading', async () => { - mockIsAppApiKeysLoading.mockReturnValue(true) - await renderModal() - expect(screen.getByRole('status')).toBeInTheDocument() - }) - - it('should show loading when dataset API keys are loading', async () => { - mockIsDatasetApiKeysLoading.mockReturnValue(true) - await renderModal() - expect(screen.getByRole('status')).toBeInTheDocument() - }) - - it('should not show loading when data is loaded', async () => { - mockIsAppApiKeysLoading.mockReturnValue(false) - await renderModal() - expect(screen.queryByRole('status')).not.toBeInTheDocument() - }) - }) - - describe('API keys list for app', () => { - const apiKeys = [ - { - id: 'key-1', - token: 'sk-abc123def456ghi789', - created_at: 1700000000, - last_used_at: 1700100000, - }, - { id: 'key-2', token: 'sk-xyz987wvu654tsr321', created_at: 1700050000, last_used_at: null }, - ] - - beforeEach(() => { - mockAppApiKeysData.mockReturnValue({ data: apiKeys }) - }) - - it('should render API keys when available', async () => { - await renderModal() - expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument() - }) - - it('should render created time for keys', async () => { - await renderModal() - expect(screen.getByText('Formatted: 1700000000')).toBeInTheDocument() - }) - - it('should render last used time for keys', async () => { - await renderModal() - expect(screen.getByText('Formatted: 1700100000')).toBeInTheDocument() - }) - - it('should render "never" for keys without last_used_at', async () => { - await renderModal() - expect(screen.getByText('appApi.never')).toBeInTheDocument() - }) - - it('should render delete button for permitted users', async () => { - await renderModal() - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThanOrEqual(2) - const deleteIcon = document.body.querySelector('.i-ri-delete-bin-line') - expect(deleteIcon).toBeInTheDocument() - }) - - it('should render delete button when canManage is true even if the workspace role is not manager', async () => { - mockIsCurrentWorkspaceManager.mockReturnValue(false) - await renderModal() - const deleteIcon = document.body.querySelector('.i-ri-delete-bin-line') - expect(deleteIcon).toBeInTheDocument() - }) - - it('should not render delete button when canManage is false even if the workspace role is manager', async () => { - mockIsCurrentWorkspaceManager.mockReturnValue(true) - await renderModal() - const deleteIcon = document.body.querySelector('.i-ri-delete-bin-line') - expect(deleteIcon).not.toBeInTheDocument() - }) - - it('should render table headers', async () => { - await renderModal() - expect(screen.getByText('appApi.apiKeyModal.secretKey')).toBeInTheDocument() - expect(screen.getByText('appApi.apiKeyModal.created')).toBeInTheDocument() - expect(screen.getByText('appApi.apiKeyModal.lastUsed')).toBeInTheDocument() - }) - }) - - describe('API keys list for dataset', () => { - const datasetKeys = [ - { - id: 'dk-1', - token: 'dk-abc123def456ghi789', - created_at: 1700000000, - last_used_at: 1700100000, - }, - ] - - beforeEach(() => { - mockDatasetApiKeysData.mockReturnValue({ data: datasetKeys }) - }) - - it('should render dataset API keys when no appId', async () => { - await renderModal() - expect(screen.getByText('dk-...k-abc123def456ghi789')).toBeInTheDocument() - }) - }) - - describe('close functionality', () => { - it('should call onClose when X icon is clicked', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - const onClose = vi.fn() - await renderModal() - - const closeIcon = document.body.querySelector('.i-heroicons-x-mark-20-solid') - expect(closeIcon).toBeInTheDocument() - - await act(async () => { - await user.click(closeIcon!) - }) - - expect(onClose).toHaveBeenCalled() - }) - }) - - describe('create new key', () => { - it('should call create API for app when button is clicked', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') - await act(async () => { - await user.click(createButton) - }) - - await waitFor(() => { - expect(mockCreateAppApikey).toHaveBeenCalledWith({ - url: '/apps/app-123/api-keys', - body: {}, - }) - }) - }) - - it('should call create API for dataset when no appId', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') - await act(async () => { - await user.click(createButton) - }) - - await waitFor(() => { - expect(mockCreateDatasetApikey).toHaveBeenCalledWith({ - url: '/datasets/api-keys', - body: {}, - }) - }) - }) - - it('should show generate modal after creating key', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') - await act(async () => { - await user.click(createButton) - }) - - await waitFor(() => { - expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument() - }) - }) - - it('should place the generated key backdrop above the API keys modal', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - mockAppApiKeysData.mockReturnValue({ - data: [ - { - id: 'key-1', - token: 'sk-abc123def456ghi789', - created_at: 1700000000, - last_used_at: null, - }, - ], - }) - await renderModal() - - const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') - await act(async () => { - await user.click(createButton) - }) - - await waitFor(() => { - expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument() - }) - - const parentDialog = screen - .getByText('appApi.apiKeyModal.apiSecretKeyTips') - .closest('[role="dialog"]') - const generatedKeyDialog = screen - .getByText('appApi.apiKeyModal.generateTips') - .closest('[role="dialog"]') - const backdrops = document.body.querySelectorAll('.bg-background-overlay') - const generatedKeyBackdrop = backdrops[1] - - expect(parentDialog).toBeInTheDocument() - expect(generatedKeyDialog).toBeInTheDocument() - expect(backdrops).toHaveLength(2) - expect( - parentDialog!.compareDocumentPosition(generatedKeyBackdrop!) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy() - expect( - generatedKeyBackdrop!.compareDocumentPosition(generatedKeyDialog!) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy() - }) - - it('should invalidate app API keys after creating', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') - await act(async () => { - await user.click(createButton) - }) - - await waitFor(() => { - expect(mockInvalidateAppApiKeys).toHaveBeenCalledWith('app-123') - }) - }) - - it('should invalidate dataset API keys after creating (no appId)', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') - await act(async () => { - await user.click(createButton) - }) - - await waitFor(() => { - expect(mockInvalidateDatasetApiKeys).toHaveBeenCalled() - }) - }) - - it('should disable create button when no workspace', async () => { - mockCurrentWorkspace.mockReturnValue({ id: '', name: '' }) - await renderModal() - - const createButton = screen - .getByText('appApi.apiKeyModal.createNewSecretKey') - .closest('button') - expect(createButton).toBeDisabled() - }) - - it('should keep create button enabled when canManage is true even if the workspace role is not editor', async () => { - mockIsCurrentWorkspaceEditor.mockReturnValue(false) - await renderModal() - - const createButton = screen - .getByText('appApi.apiKeyModal.createNewSecretKey') - .closest('button') - expect(createButton).not.toBeDisabled() - }) - - it('should disable create button when canManage is false even if the workspace role is editor', async () => { - mockIsCurrentWorkspaceEditor.mockReturnValue(true) - await renderModal() - - const createButton = screen - .getByText('appApi.apiKeyModal.createNewSecretKey') - .closest('button') - expect(createButton).toBeDisabled() - }) - }) - - describe('delete key', () => { - const apiKeys = [ - { - id: 'key-1', - token: 'sk-abc123def456ghi789', - created_at: 1700000000, - last_used_at: 1700100000, - }, - ] - - beforeEach(() => { - mockAppApiKeysData.mockReturnValue({ data: apiKeys }) - }) - - it('should render delete button for permitted users', async () => { - await renderModal() - - const actionButtons = screen.getAllByRole('button') - expect(actionButtons.length).toBeGreaterThanOrEqual(3) - }) - - it('should render API key row with actions', async () => { - await renderModal() - - expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument() - }) - - it('should have action buttons in the key row', async () => { - await renderModal() - - const actionContainers = document.body.querySelectorAll('[class*="space-x-2"]') - expect(actionContainers.length).toBeGreaterThan(0) - }) - - it('should have delete button visible for permitted users', async () => { - await renderModal() - - const deleteIcon = document.body.querySelector('.i-ri-delete-bin-line') - const deleteButton = deleteIcon?.closest('button') - expect(deleteButton).toBeInTheDocument() - }) - - it('should show confirm dialog when delete button is clicked', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] - expect(deleteButton).toBeInTheDocument() - - await act(async () => { - await user.click(deleteButton!) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() - expect(screen.getByText('appApi.actionMsg.deleteConfirmTips')).toBeInTheDocument() - }) - await flushTransitions() - }) - - it('should call delete API for app when confirmed', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] - await act(async () => { - await user.click(deleteButton!) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() - }) - await flushTransitions() - - const confirmButton = screen.getByText('common.operation.confirm') - await act(async () => { - await user.click(confirmButton) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(mockDelAppApikey).toHaveBeenCalledWith({ - url: '/apps/app-123/api-keys/key-1', - params: {}, - }) - }) - }) - - it('should invalidate app API keys after deleting', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] - await act(async () => { - await user.click(deleteButton!) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() - }) - await flushTransitions() - - const confirmButton = screen.getByText('common.operation.confirm') - await act(async () => { - await user.click(confirmButton) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(mockInvalidateAppApiKeys).toHaveBeenCalledWith('app-123') - }) - }) - - it('should close confirm dialog and clear delKeyId when cancel is clicked', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] - await act(async () => { - await user.click(deleteButton!) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() - }) - await flushTransitions() - - const cancelButton = screen.getByText('common.operation.cancel') - await act(async () => { - await user.click(cancelButton) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(screen.queryByText('appApi.actionMsg.deleteConfirmTitle')).not.toBeInTheDocument() - }) - - expect(mockDelAppApikey).not.toHaveBeenCalled() - }) - - it('should close confirm dialog when Escape is pressed', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] - await act(async () => { - await user.click(deleteButton!) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() - }) - await flushTransitions() - - await act(async () => { - fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(screen.queryByText('appApi.actionMsg.deleteConfirmTitle')).not.toBeInTheDocument() - }) - }) - }) - - describe('delete key for dataset', () => { - const datasetKeys = [ - { - id: 'dk-1', - token: 'dk-abc123def456ghi789', - created_at: 1700000000, - last_used_at: 1700100000, - }, - ] - - beforeEach(() => { - mockDatasetApiKeysData.mockReturnValue({ data: datasetKeys }) - }) - - it('should call delete API for dataset when no appId', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] - await act(async () => { - await user.click(deleteButton!) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() - }) - await flushTransitions() - - const confirmButton = screen.getByText('common.operation.confirm') - await act(async () => { - await user.click(confirmButton) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(mockDelDatasetApikey).toHaveBeenCalledWith({ - url: '/datasets/api-keys/dk-1', - params: {}, - }) - }) - }) - - it('should invalidate dataset API keys after deleting', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] - await act(async () => { - await user.click(deleteButton!) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() - }) - await flushTransitions() - - const confirmButton = screen.getByText('common.operation.confirm') - await act(async () => { - await user.click(confirmButton) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(mockInvalidateDatasetApiKeys).toHaveBeenCalled() - }) - }) - }) - - describe('token truncation', () => { - it('should truncate token correctly', async () => { - const apiKeys = [ + it('renders the app API key dataset selected by appId', async () => { + appApiKeys = { + data: [ { - id: 'key-1', - token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', - created_at: 1700000000, - last_used_at: null, + id: 'app-key-1', + token: 'app-secret-token-123456789', + type: 'app', + created_at: 1, }, - ] - mockAppApiKeysData.mockReturnValue({ data: apiKeys }) + ], + } - await renderModal() + await renderModal('app-123') - expect(screen.getByText('sk-...qrstuvwxyz1234567890')).toBeInTheDocument() + expect(screen.getByText('app...cret-token-123456789')).toBeInTheDocument() + }) + + it('renders the workspace dataset API keys without an appId', async () => { + datasetApiKeys = { + data: [ + { + id: 'dataset-key-1', + token: 'dataset-secret-token-123456789', + type: 'dataset', + created_at: 1, + }, + ], + } + + await renderModal() + + expect(screen.getByText('dat...cret-token-123456789')).toBeInTheDocument() + }) + + it('creates an app API key through the generated mutation input', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal('app-123') + + await user.click(screen.getByText('appApi.apiKeyModal.createNewSecretKey')) + + expect(createAppApiKey).toHaveBeenCalledWith( + { params: { resource_id: 'app-123' } }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ) + expect(await screen.findByText('new-app-token-123')).toBeInTheDocument() + }) + + it('creates a dataset API key through the generated mutation', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal() + + await user.click(screen.getByText('appApi.apiKeyModal.createNewSecretKey')) + + expect(createDatasetApiKey).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ) + expect(await screen.findByText('new-dataset-token-123')).toBeInTheDocument() + }) + + it('deletes an app API key through the generated mutation input', async () => { + appApiKeys = { + data: [ + { + id: 'app-key-1', + token: 'app-secret-token-123456789', + type: 'app', + created_at: 1, + }, + ], + } + await renderModal('app-123') + + await confirmFirstKeyDeletion() + + await waitFor(() => { + expect(deleteAppApiKey).toHaveBeenCalledWith({ + params: { resource_id: 'app-123', api_key_id: 'app-key-1' }, + }) }) }) - describe('styling', () => { - it('should render modal with expected structure', async () => { - await renderModal() - expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() - }) + it('deletes a dataset API key through the generated mutation input', async () => { + datasetApiKeys = { + data: [ + { + id: 'dataset-key-1', + token: 'dataset-secret-token-123456789', + type: 'dataset', + created_at: 1, + }, + ], + } + await renderModal() - it('should render create button with flex styling', async () => { - await renderModal() - const flexContainers = document.body.querySelectorAll('[class*="flex"]') - expect(flexContainers.length).toBeGreaterThan(0) - }) - }) + await confirmFirstKeyDeletion() - describe('empty state', () => { - it('should not render table when no keys', async () => { - mockAppApiKeysData.mockReturnValue({ data: [] }) - await renderModal() - - expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument() - }) - - it('should not render table when data is null', async () => { - mockAppApiKeysData.mockReturnValue(null) - await renderModal() - - expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument() - }) - }) - - describe('SecretKeyGenerateModal', () => { - it('should close generate modal on close', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await renderModal() - - const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') - await act(async () => { - await user.click(createButton) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument() - }) - - const okButton = screen.getByText('appApi.actionMsg.ok') - await act(async () => { - await user.click(okButton) - vi.runAllTimers() - }) - - await waitFor(() => { - expect(screen.queryByText('appApi.apiKeyModal.generateTips')).not.toBeInTheDocument() + await waitFor(() => { + expect(deleteDatasetApiKey).toHaveBeenCalledWith({ + params: { api_key_id: 'dataset-key-1' }, }) }) }) diff --git a/web/app/components/develop/secret-key/secret-key-generate.tsx b/web/app/components/develop/secret-key/secret-key-generate.tsx index 71fdc2121db..885824a1cd7 100644 --- a/web/app/components/develop/secret-key/secret-key-generate.tsx +++ b/web/app/components/develop/secret-key/secret-key-generate.tsx @@ -1,6 +1,5 @@ 'use client' -import type { CreateApiKeyResponse } from '@/models/app' -import { XMarkIcon } from '@heroicons/react/20/solid' +import type { ApiKeyItem } from '@dify/contracts/api/console/apps/types.gen' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' @@ -11,7 +10,7 @@ import s from './style.module.css' type ISecretKeyGenerateModalProps = { isShow: boolean onClose: () => void - newKey?: CreateApiKeyResponse + newKey?: Pick className?: string } @@ -40,7 +39,14 @@ const SecretKeyGenerateModal = ({
- +

{t(($) => $['apiKeyModal.generateTips'], { ns: 'appApi' })} diff --git a/web/app/components/develop/secret-key/secret-key-modal.tsx b/web/app/components/develop/secret-key/secret-key-modal.tsx index b56cc7a5fb1..2fdc88570c2 100644 --- a/web/app/components/develop/secret-key/secret-key-modal.tsx +++ b/web/app/components/develop/secret-key/secret-key-modal.tsx @@ -1,5 +1,5 @@ 'use client' -import type { CreateApiKeyResponse } from '@/models/app' +import type { ApiKeyItem } from '@dify/contracts/api/console/apps/types.gen' import { AlertDialog, AlertDialogActions, @@ -12,6 +12,7 @@ import { import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { skipToken, useMutation, useQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,13 +21,7 @@ import CopyFeedback from '@/app/components/base/copy-feedback' import Loading from '@/app/components/base/loading' import { currentWorkspaceAtom } from '@/context/workspace-state' import useTimestamp from '@/hooks/use-timestamp' -import { createApikey as createAppApikey, delApikey as delAppApikey } from '@/service/apps' -import { - createApikey as createDatasetApikey, - delApikey as delDatasetApikey, -} from '@/service/datasets' -import { useDatasetApiKeys, useInvalidateDatasetApiKeys } from '@/service/knowledge/use-dataset' -import { useAppApiKeys, useInvalidateAppApiKeys } from '@/service/use-apps' +import { consoleQuery } from '@/service/client' import SecretKeyGenerateModal from './secret-key-generate' import s from './style.module.css' @@ -43,13 +38,22 @@ const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKe const currentWorkspace = useAtomValue(currentWorkspaceAtom) const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [isVisible, setIsVisible] = useState(false) - const [newKey, setNewKey] = useState(undefined) - const invalidateAppApiKeys = useInvalidateAppApiKeys() - const invalidateDatasetApiKeys = useInvalidateDatasetApiKeys() - const { data: appApiKeys, isLoading: isAppApiKeysLoading } = useAppApiKeys(appId, { - enabled: !!appId && isShow, - }) - const { data: datasetApiKeys, isLoading: isDatasetApiKeysLoading } = useDatasetApiKeys({ + const [newKey, setNewKey] = useState | undefined>(undefined) + const createAppApiKey = useMutation(consoleQuery.apps.byResourceId.apiKeys.post.mutationOptions()) + const deleteAppApiKey = useMutation( + consoleQuery.apps.byResourceId.apiKeys.byApiKeyId.delete.mutationOptions(), + ) + const createDatasetApiKey = useMutation(consoleQuery.datasets.apiKeys.post.mutationOptions()) + const deleteDatasetApiKey = useMutation( + consoleQuery.datasets.apiKeys.byApiKeyId.delete.mutationOptions(), + ) + const { data: appApiKeys, isLoading: isAppApiKeysLoading } = useQuery( + consoleQuery.apps.byResourceId.apiKeys.get.queryOptions({ + input: appId && isShow ? { params: { resource_id: appId } } : skipToken, + }), + ) + const { data: datasetApiKeys, isLoading: isDatasetApiKeysLoading } = useQuery({ + ...consoleQuery.datasets.apiKeys.get.queryOptions(), enabled: !appId && isShow, }) const apiKeysList = appId ? appApiKeys : datasetApiKeys @@ -62,27 +66,48 @@ const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKe if (!canManage) return if (!delKeyID) return - const delApikey = appId ? delAppApikey : delDatasetApikey - const params = appId - ? { url: `/apps/${appId}/api-keys/${delKeyID}`, params: {} } - : { url: `/datasets/api-keys/${delKeyID}`, params: {} } - await delApikey(params) - if (appId) invalidateAppApiKeys(appId) - else invalidateDatasetApiKeys() + try { + if (appId) { + deleteAppApiKey.mutate({ + params: { resource_id: appId, api_key_id: delKeyID }, + }) + return + } + + deleteDatasetApiKey.mutate({ + params: { api_key_id: delKeyID }, + }) + } catch (error) { + console.error(error) + } } const onCreate = async () => { if (!currentWorkspace.id || !canManage) return - const params = appId - ? { url: `/apps/${appId}/api-keys`, body: {} } - : { url: '/datasets/api-keys', body: {} } - const createApikey = appId ? createAppApikey : createDatasetApikey - const res = await createApikey(params) - setIsVisible(true) - setNewKey(res) - if (appId) invalidateAppApiKeys(appId) - else invalidateDatasetApiKeys() + try { + if (appId) { + createAppApiKey.mutate( + { params: { resource_id: appId } }, + { + onSuccess: (apiKey) => { + setIsVisible(true) + setNewKey(apiKey) + }, + }, + ) + return + } + + createDatasetApiKey.mutate(undefined, { + onSuccess: (apiKey) => { + setIsVisible(true) + setNewKey(apiKey) + }, + }) + } catch (error) { + console.error(error) + } } const generateToken = (token: string) => { diff --git a/web/app/components/explore/installed-app-navigation/__tests__/infinite-scroll-sentinel.spec.tsx b/web/app/components/explore/installed-app-navigation/__tests__/infinite-scroll-sentinel.spec.tsx index 2a195aa6622..4f50ba9d37c 100644 --- a/web/app/components/explore/installed-app-navigation/__tests__/infinite-scroll-sentinel.spec.tsx +++ b/web/app/components/explore/installed-app-navigation/__tests__/infinite-scroll-sentinel.spec.tsx @@ -1,70 +1,95 @@ -import type { RefObject } from 'react' import { act, render } from '@testing-library/react' +import { useRef } from 'react' import { InfiniteScrollSentinel } from '../infinite-scroll-sentinel' -describe('InfiniteScrollSentinel', () => { - it('does not observe again after a next-page request completes', () => { - const fetchNextPage = vi.fn(() => Promise.resolve()) - const scrollRootRef: RefObject = { - current: document.createElement('div'), - } - const observerConstructed = vi.fn() +type MockObserver = { + callback: IntersectionObserverCallback +} +const observers: MockObserver[] = [] + +function Harness({ + canLoadMore, + fetchNextPage, +}: { + canLoadMore: boolean + fetchNextPage: () => Promise +}) { + const scrollRootRef = useRef(null) + + return ( +

+ +
+ ) +} + +describe('InfiniteScrollSentinel', () => { + beforeEach(() => { + observers.length = 0 vi.stubGlobal( 'IntersectionObserver', class MockIntersectionObserver { - private readonly callback: IntersectionObserverCallback + callback: IntersectionObserverCallback + disconnect = vi.fn() + observe = vi.fn() + root = null + rootMargin = '' + thresholds = [] + takeRecords = () => [] + unobserve = vi.fn() constructor(callback: IntersectionObserverCallback) { this.callback = callback - observerConstructed() + observers.push({ callback }) } - - observe() { - this.callback( - [{ isIntersecting: true } as IntersectionObserverEntry], - this as unknown as IntersectionObserver, - ) - } - - disconnect() {} - unobserve() {} }, ) + }) - const { rerender } = render( - , - ) + afterEach(() => { + vi.unstubAllGlobals() + }) - expect(fetchNextPage).toHaveBeenCalledOnce() + it('does not fetch again while busy and resumes when the query can load again', () => { + const fetchNextPage = vi.fn().mockResolvedValue(undefined) + const { rerender } = render() act(() => { - rerender( - , - ) + observers + .at(-1) + ?.callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) }) + expect(fetchNextPage).toHaveBeenCalledOnce() + + rerender() act(() => { - rerender( - , - ) + observers + .at(-1) + ?.callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + expect(fetchNextPage).toHaveBeenCalledOnce() + + rerender() + act(() => { + observers + .at(-1) + ?.callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) }) - expect(observerConstructed).toHaveBeenCalledOnce() - expect(fetchNextPage).toHaveBeenCalledOnce() + expect(fetchNextPage).toHaveBeenCalledTimes(2) }) }) diff --git a/web/app/components/explore/installed-app-navigation/infinite-scroll-sentinel.tsx b/web/app/components/explore/installed-app-navigation/infinite-scroll-sentinel.tsx index 65528719714..27be3e01b83 100644 --- a/web/app/components/explore/installed-app-navigation/infinite-scroll-sentinel.tsx +++ b/web/app/components/explore/installed-app-navigation/infinite-scroll-sentinel.tsx @@ -4,47 +4,42 @@ import type { RefObject } from 'react' import { useEffect, useEffectEvent, useRef } from 'react' type InfiniteScrollSentinelProps = { - canFetchNextPage: boolean + canLoadMore: boolean fetchNextPage: () => Promise - isFetchingNextPage: boolean scrollRootRef: RefObject } export const InfiniteScrollSentinel = ({ - canFetchNextPage, + canLoadMore, fetchNextPage, - isFetchingNextPage, scrollRootRef, }: InfiniteScrollSentinelProps) => { const sentinelRef = useRef(null) const handleIntersection = useEffectEvent((entry: IntersectionObserverEntry) => { - if (entry.isIntersecting && canFetchNextPage && !isFetchingNextPage) void fetchNextPage() + if (entry.isIntersecting && canLoadMore) void fetchNextPage() }) useEffect(() => { const scrollRoot = scrollRootRef.current const sentinel = sentinelRef.current - if ( - !canFetchNextPage || - !scrollRoot || - !sentinel || - typeof IntersectionObserver === 'undefined' - ) + if (!canLoadMore || !scrollRoot || !sentinel || typeof IntersectionObserver === 'undefined') return + const preloadDistance = Math.max(160, Math.min(scrollRoot.clientHeight * 0.25, 320)) const observer = new IntersectionObserver( ([entry]) => { if (entry) handleIntersection(entry) }, { root: scrollRoot, - rootMargin: '0px 0px 64px 0px', + rootMargin: `0px 0px ${preloadDistance}px 0px`, + threshold: 0, }, ) observer.observe(sentinel) return () => observer.disconnect() - }, [canFetchNextPage, scrollRootRef]) + }, [canLoadMore, scrollRootRef]) return
} diff --git a/web/app/components/explore/installed-app-navigation/pagination-skeleton.tsx b/web/app/components/explore/installed-app-navigation/pagination-skeleton.tsx index 796813907bb..5500f171636 100644 --- a/web/app/components/explore/installed-app-navigation/pagination-skeleton.tsx +++ b/web/app/components/explore/installed-app-navigation/pagination-skeleton.tsx @@ -1,8 +1,10 @@ -const skeletonClassName = - 'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none' +const skeletonClassName = 'rounded bg-text-quaternary opacity-20' export const InstalledAppPaginationSkeleton = () => ( -
+
diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx index 74bb06de0e0..60e6f7862a8 100644 --- a/web/app/components/explore/sidebar/index.tsx +++ b/web/app/components/explore/sidebar/index.tsx @@ -101,6 +101,9 @@ const SideBar = () => { } const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length + const canLoadMore = Boolean( + installedAppsQuery.hasNextPage && !installedAppsQuery.isFetching && !installedAppsQuery.error, + ) const webAppsLabelId = React.useId() const installedAppItems = installedApps.map((installedApp, index) => ( @@ -205,13 +208,12 @@ const SideBar = () => { {installedAppItems} {installedAppsQuery.isFetchingNextPage && } installedAppsQuery.fetchNextPage({ cancelRefetch: false, }) } - isFetchingNextPage={installedAppsQuery.isFetchingNextPage} scrollRootRef={scrollRef} /> @@ -230,13 +232,12 @@ const SideBar = () => { {installedAppItems} {installedAppsQuery.isFetchingNextPage && } installedAppsQuery.fetchNextPage({ cancelRefetch: false, }) } - isFetchingNextPage={installedAppsQuery.isFetchingNextPage} scrollRootRef={scrollRef} />
diff --git a/web/app/components/header/account-setting/collapse/index.tsx b/web/app/components/header/account-setting/collapse/index.tsx index 1121bf48419..bfe3ed28f18 100644 --- a/web/app/components/header/account-setting/collapse/index.tsx +++ b/web/app/components/header/account-setting/collapse/index.tsx @@ -1,4 +1,3 @@ -import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' import { useState } from 'react' @@ -6,14 +5,20 @@ export type IItem = { key: string name: string } -type ICollapse = { +type ICollapse = { title: string | undefined - items: IItem[] - renderItem: (item: IItem) => React.ReactNode - onSelect?: (item: IItem) => void + items: T[] + renderItem: (item: T) => React.ReactNode + onSelect?: (item: T) => void wrapperClassName?: string } -const Collapse = ({ title, items, renderItem, onSelect, wrapperClassName }: ICollapse) => { +const Collapse = ({ + title, + items, + renderItem, + onSelect, + wrapperClassName, +}: ICollapse) => { const [open, setOpen] = useState(false) const toggle = () => setOpen(!open) @@ -26,17 +31,10 @@ const Collapse = ({ title, items, renderItem, onSelect, wrapperClassName }: ICol onClick={toggle} > {title} - {open ? ( -