Compare commits

...

11 Commits

Author SHA1 Message Date
Stephen Zhou
93ea88d185 Merge branch 'main' into refactor/migrate-app-detail-to-tanstack-query 2026-01-19 15:13:04 +08:00
yyh
6f40d79538 chore: prune unused eslint suppressions after merge 2026-01-19 13:33:53 +08:00
yyh
171dd120ca Merge branch 'main' into refactor/migrate-app-detail-to-tanstack-query
Resolve eslint-suppressions.json conflicts by keeping both sets of entries
2026-01-19 13:31:57 +08:00
yyh
e71ebc19e6 Merge branch 'main' into refactor/migrate-app-detail-to-tanstack-query 2026-01-19 11:17:02 +08:00
yyh
19137c173f Merge remote-tracking branch 'origin/main' into refactor/migrate-app-detail-to-tanstack-query
# Conflicts:
#	web/app/components/app/switch-app-modal/index.spec.tsx
#	web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx
#	web/app/components/workflow-app/components/workflow-header/index.spec.tsx
2026-01-19 11:14:52 +08:00
yyh
e69f1a1f46 fix: use useInvalidateAppDetail hook in app-card.tsx for consistency 2026-01-18 23:55:30 +08:00
yyh
14f0c5dd38 fix: use useInvalidateAppDetail hook in card-view.tsx for consistency 2026-01-18 23:53:49 +08:00
yyh
23f1adc833 fix: restore mobile sidebar collapse behavior after appDetail loads
Merge the two sidebar useEffects to prevent race condition where
appDetail loading could override the mobile collapse state.
Now mobile always collapses regardless of when appDetail resolves.
2026-01-18 23:47:48 +08:00
yyh
1fe46ce0b8 fix: update test files to use TanStack Query pattern for appDetail
Update test files to reflect the appDetail migration from Zustand to TanStack Query:
- Replace setAppDetail mocks with useInvalidateAppDetail
- Add useParams mock from next/navigation
- Add useAppDetail mock from @/service/use-apps
- Remove deprecated fetchAppDetail + setAppDetail patterns
- Fix marketplace test mock data types
2026-01-18 23:46:14 +08:00
yyh
b3acb74331 fix: address code review issues from appDetail migration
- app-info.tsx: use useInvalidateAppDetail hook instead of local queryClient call
- app-publisher.tsx: convert isAppAccessSet from useEffect+useState to useMemo
- Prune unused eslint suppressions
2026-01-18 23:11:54 +08:00
yyh
91856b09ca refactor: migrate appDetail from Zustand to TanStack Query
- Remove appDetail and setAppDetail from Zustand store
- Use useAppDetail hook for server state management
- Child components now call useAppDetail(appId) directly via useParams()
- Replace setAppDetail calls with useInvalidateAppDetail for cache invalidation
- Keep only client UI state in Zustand (sidebar, modals)
- Split sidebar initialization useEffect for clearer separation of concerns
- Update test mocks to use TanStack Query pattern
- Fix missing dependencies in use-checklist.ts useMemo/useCallback hooks
2026-01-18 23:07:33 +08:00
49 changed files with 605 additions and 515 deletions

View File

@@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import type { NavIcon } from '@/app/components/app-sidebar/navLink'
import type { App } from '@/types/app'
import {
RiDashboard2Fill,
RiDashboard2Line,
@@ -12,13 +11,11 @@ import {
RiTerminalWindowFill,
RiTerminalWindowLine,
} from '@remixicon/react'
import { useUnmount } from 'ahooks'
import dynamic from 'next/dynamic'
import { usePathname, useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import AppSideBar from '@/app/components/app-sidebar'
import { useStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
@@ -26,7 +23,7 @@ import { useStore as useTagStore } from '@/app/components/base/tag-management/st
import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { fetchAppDetailDirect } from '@/service/apps'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import s from './style.module.css'
@@ -41,47 +38,41 @@ export type IAppDetailLayoutProps = {
}
const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const {
children,
appId, // get appId in path
} = props
const { children, appId } = props
const { t } = useTranslation()
const router = useRouter()
const pathname = usePathname()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({
appDetail: state.appDetail,
setAppDetail: state.setAppDetail,
setAppSidebarExpand: state.setAppSidebarExpand,
})))
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false)
const [appDetailRes, setAppDetailRes] = useState<App | null>(null)
const [navigation, setNavigation] = useState<Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace } = useAppContext()
const setAppSidebarExpand = useStore(s => s.setAppSidebarExpand)
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const { data: appDetail, isPending, error } = useAppDetail(appId)
const navigation = useMemo(() => {
if (!appDetail)
return []
const mode = appDetail.mode
const isWorkflowMode = mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT
return [
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
href: `/app/${appId}/${isWorkflowMode ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine as NavIcon,
selectedIcon: RiTerminalWindowFill as NavIcon,
}]
: []
),
{
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
icon: RiTerminalBoxLine as NavIcon,
selectedIcon: RiTerminalBoxFill as NavIcon,
},
...(isCurrentWorkspaceEditor
? [{
@@ -89,74 +80,64 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
icon: RiFileList3Line as NavIcon,
selectedIcon: RiFileList3Fill as NavIcon,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
icon: RiDashboard2Line as NavIcon,
selectedIcon: RiDashboard2Fill as NavIcon,
},
]
return navConfig
}, [t])
}, [appDetail, appId, isCurrentWorkspaceEditor, t])
useDocumentTitle(appDetail?.name || t('menus.appDetail', { ns: 'common' }))
useEffect(() => {
if (appDetail) {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
// TODO: consider screen size and mode
// if ((appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === 'workflow') && (pathname).endsWith('workflow'))
// setAppSidebarExpand('collapse')
}
}, [appDetail, isMobile])
useEffect(() => {
setAppDetail()
setIsLoadingAppDetail(true)
fetchAppDetailDirect({ url: '/apps', id: appId }).then((res: App) => {
setAppDetailRes(res)
}).catch((e: any) => {
if (e.status === 404)
router.replace('/apps')
}).finally(() => {
setIsLoadingAppDetail(false)
})
}, [appId, pathname])
useEffect(() => {
if (!appDetailRes || !currentWorkspace.id || isLoadingCurrentWorkspace || isLoadingAppDetail)
if (!appDetail)
return
const res = appDetailRes
// redirection
const canIEditApp = isCurrentWorkspaceEditor
if (!canIEditApp && (pathname.endsWith('configuration') || pathname.endsWith('workflow') || pathname.endsWith('logs'))) {
router.replace(`/app/${appId}/overview`)
return
}
if ((res.mode === AppModeEnum.WORKFLOW || res.mode === AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('configuration')) {
router.replace(`/app/${appId}/workflow`)
}
else if ((res.mode !== AppModeEnum.WORKFLOW && res.mode !== AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('workflow')) {
router.replace(`/app/${appId}/configuration`)
if (isMobile) {
setAppSidebarExpand('collapse')
}
else {
setAppDetail({ ...res, enable_sso: false })
setNavigation(getNavigationConfig(appId, isCurrentWorkspaceEditor, res.mode))
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
setAppSidebarExpand(localeMode)
}
}, [appDetailRes, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace])
}, [appDetail, isMobile, setAppSidebarExpand])
useUnmount(() => {
setAppDetail()
})
useEffect(() => {
if (!appDetail || isLoadingCurrentWorkspace)
return
if (!appDetail) {
const mode = appDetail.mode
const isWorkflowMode = mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT
if (!isCurrentWorkspaceEditor) {
const restrictedPaths = ['configuration', 'workflow', 'logs']
if (restrictedPaths.some(p => pathname.endsWith(p))) {
router.replace(`/app/${appId}/overview`)
return
}
}
if (isWorkflowMode && pathname.endsWith('configuration'))
router.replace(`/app/${appId}/workflow`)
else if (!isWorkflowMode && pathname.endsWith('workflow'))
router.replace(`/app/${appId}/configuration`)
}, [appDetail, isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, pathname, appId, router])
useEffect(() => {
if (error) {
const httpError = error as { status?: number }
if (httpError.status === 404)
router.replace('/apps')
}
}, [error, router])
if (isPending) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
@@ -164,13 +145,12 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
)
}
if (!appDetail)
return null
return (
<div className={cn(s.app, 'relative flex', 'overflow-hidden')}>
{appDetail && (
<AppSideBar
navigation={navigation}
/>
)}
<AppSideBar navigation={navigation} />
<div className="grow overflow-hidden bg-components-panel-bg">
{children}
</div>
@@ -180,4 +160,5 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
</div>
)
}
export default React.memo(AppDetailLayout)

View File

@@ -5,13 +5,11 @@ import type { BlockEnum } from '@/app/components/workflow/types'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import type { App } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
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 Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/toast'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
@@ -19,11 +17,11 @@ import { isTriggerNode } from '@/app/components/workflow/types'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useDocLink } from '@/context/i18n'
import {
fetchAppDetail,
updateAppSiteAccessToken,
updateAppSiteConfig,
updateAppSiteStatus,
} from '@/service/apps'
import { useAppDetail, useInvalidateAppDetail } from '@/service/use-apps'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
@@ -38,8 +36,8 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useContext(ToastContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const { data: appDetail } = useAppDetail(appId)
const invalidateAppDetail = useInvalidateAppDetail()
const isWorkflowApp = appDetail?.mode === AppModeEnum.WORKFLOW
const showMCPCard = isInPanel
@@ -89,21 +87,13 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
? buildTriggerModeMessage(t('mcp.server.title', { ns: 'tools' }))
: null
const updateAppDetail = async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setAppDetail({ ...res })
}
catch (error) { console.error(error) }
}
const handleCallbackResult = (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
const type = err ? 'error' : 'success'
message ||= (type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully')
if (type === 'success')
updateAppDetail()
invalidateAppDetail(appId)
notify({
type,

View File

@@ -8,8 +8,8 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter'
import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart'
import { useStore as useAppStore } from '@/app/components/app/store'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppDetail } from '@/service/use-apps'
import LongTimeRangePicker from './long-time-range-picker'
import TimeRangePicker from './time-range-picker'
@@ -34,7 +34,7 @@ export type IChartViewProps = {
export default function ChartView({ appId, headerRight }: IChartViewProps) {
const { t } = useTranslation()
const appDetail = useAppStore(state => state.appDetail)
const { data: appDetail } = useAppDetail(appId)
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
const isWorkflow = appDetail?.mode === 'workflow'
const [period, setPeriod] = useState<PeriodParams>(IS_CLOUD_EDITION

View File

@@ -12,13 +12,12 @@ import {
RiFileUploadLine,
} from '@remixicon/react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import { ToastContext } from '@/app/components/base/toast'
@@ -26,7 +25,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'
import { useAppDetail, useInvalidateAppDetail, useInvalidateAppList } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
@@ -64,9 +63,10 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { replace } = useRouter()
const { appId } = useParams()
const { onPlanInfoChanged } = useProviderContext()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const { data: appDetail } = useAppDetail(appId as string)
const invalidateAppDetail = useInvalidateAppDetail()
const invalidateAppList = useInvalidateAppList()
const [open, setOpen] = useState(openState)
const [showEditModal, setShowEditModal] = useState(false)
@@ -89,7 +89,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
if (!appDetail)
return
try {
const app = await updateAppInfo({
await updateAppInfo({
appID: appDetail.id,
name,
icon_type,
@@ -104,12 +104,12 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
type: 'success',
message: t('editDone', { ns: 'app' }),
})
setAppDetail(app)
invalidateAppDetail(appId as string)
}
catch {
notify({ type: 'error', message: t('editFailed', { ns: 'app' }) })
}
}, [appDetail, notify, setAppDetail, t])
}, [appDetail, notify, invalidateAppDetail, appId, t])
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
if (!appDetail)
@@ -195,7 +195,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) })
invalidateAppList()
onPlanInfoChanged()
setAppDetail()
replace('/apps')
}
catch (e: any) {
@@ -205,7 +204,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
})
}
setShowConfirmDelete(false)
}, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t])
}, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, t])
const { isCurrentWorkspaceEditor } = useAppContext()
@@ -242,7 +241,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
]
const secondaryOperations: Operation[] = [
// Import DSL (conditional)
...(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)
? [{
id: 'import',
@@ -255,7 +253,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
},
}]
: [],
// Divider
{
id: 'divider-1',
title: '',
@@ -263,7 +260,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
onClick: () => { /* divider has no action */ },
type: 'divider' as const,
},
// Delete operation
{
id: 'delete',
title: t('operation.delete', { ns: 'common' }),
@@ -276,7 +272,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
},
]
// Keep the switch operation separate as it's not part of the main operations
const switchOperation = (appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT)
? {
id: 'switch',
@@ -370,11 +365,9 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
<div className="system-2xs-medium-uppercase text-text-tertiary">{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('types.advanced', { ns: 'app' }) : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('types.agent', { ns: 'app' }) : appDetail.mode === AppModeEnum.CHAT ? t('types.chatbot', { ns: 'app' }) : appDetail.mode === AppModeEnum.COMPLETION ? t('types.completion', { ns: 'app' }) : t('types.workflow', { ns: 'app' })}</div>
</div>
</div>
{/* description */}
{appDetail.description && (
<div className="system-xs-regular overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal break-words text-text-tertiary">{appDetail.description}</div>
)}
{/* operations */}
<AppOperations
gap={4}
primaryOperations={primaryOperations}
@@ -386,7 +379,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{/* Switch operation (if available) */}
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button

View File

@@ -3,16 +3,17 @@ import {
RiEqualizer2Line,
RiMenuLine,
} from '@remixicon/react'
import { useParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useAppContext } from '@/context/app-context'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import AppIcon from '../base/app-icon'
@@ -31,8 +32,9 @@ type Props = {
const AppSidebarDropdown = ({ navigation }: Props) => {
const { t } = useTranslation()
const { appId } = useParams()
const { isCurrentWorkspaceEditor } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
const { data: appDetail } = useAppDetail(appId as string)
const [detailExpand, setDetailExpand] = useState(false)
const [open, doSetOpen] = useState(false)

View File

@@ -15,6 +15,7 @@ import {
RiVerifiedBadgeLine,
} from '@remixicon/react'
import { useKeyPress } from 'ahooks'
import { useParams } from 'next/navigation'
import {
memo,
useCallback,
@@ -24,7 +25,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
@@ -41,8 +41,8 @@ import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { useAppDetail, useInvalidateAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import Divider from '../../base/divider'
@@ -139,15 +139,15 @@ const AppPublisher = ({
startNodeLimitExceeded = false,
}: AppPublisherProps) => {
const { t } = useTranslation()
const { appId } = useParams()
const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false)
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(s => s.setAppDetail)
const { data: appDetail } = useAppDetail(appId as string)
const invalidateAppDetail = useInvalidateAppDetail()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { formatTimeFromNow } = useFormatTimeFromNow()
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
@@ -177,16 +177,12 @@ const AppPublisher = ({
refetch()
}, [open, appDetail, refetch, systemFeatures])
useEffect(() => {
if (appDetail && appAccessSubjects) {
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
setIsAppAccessSet(false)
else
setIsAppAccessSet(true)
}
else {
setIsAppAccessSet(true)
}
const isAppAccessSet = useMemo(() => {
if (!appDetail || !appAccessSubjects)
return true
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
return false
return true
}, [appAccessSubjects, appDetail])
const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
@@ -239,16 +235,15 @@ const AppPublisher = ({
}, [appDetail?.id, openAsyncWindow])
const handleAccessControlUpdate = useCallback(async () => {
if (!appDetail)
if (!appId)
return
try {
const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id })
setAppDetail(res)
invalidateAppDetail(appId as string)
}
finally {
setShowAppAccessControl(false)
}
}, [appDetail, setAppDetail])
}, [appId, invalidateAppDetail])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()

View File

@@ -4,11 +4,11 @@ import type { Item as SelectItem } from './type-select'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputVar, MoreInfo, UploadFileSetting } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { useParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import Checkbox from '@/app/components/base/checkbox'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
@@ -22,6 +22,7 @@ import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import ConfigContext from '@/context/debug-configuration'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum, TransferMethod } from '@/types/app'
import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
import ConfigSelect from '../config-select'
@@ -72,10 +73,11 @@ const ConfigModal: FC<IConfigModalProps> = ({
}) => {
const { modelConfig } = useContext(ConfigContext)
const { t } = useTranslation()
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const [tempPayload, setTempPayload] = useState<InputVar>(() => normalizeSelectDefaultValue(payload || getNewVarInWorkflow('') as any))
const { type, label, variable, options, max_length } = tempPayload
const modalRef = useRef<HTMLDivElement>(null)
const appDetail = useAppStore(state => state.appDetail)
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
const jsonSchemaStr = useMemo(() => {
const isJsonObject = type === InputVarType.jsonObject

View File

@@ -2,11 +2,13 @@ import type { ReactNode } from 'react'
import type { IConfigVarProps } from './index'
import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import type { App } from '@/types/app'
import { act, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import DebugConfigurationContext from '@/context/debug-configuration'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from './index'
@@ -38,6 +40,15 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
vi.mock('next/navigation', () => ({
useParams: () => ({
appId: 'test-app-id',
}),
}))
vi.mock('@/service/use-apps')
const mockUseAppDetail = vi.mocked(useAppDetail)
type SortableItem = {
id: string
variable: PromptVariable
@@ -85,6 +96,18 @@ const createPromptVariable = (overrides: Partial<PromptVariable> = {}): PromptVa
}
}
function setupUseAppDetailMock() {
mockUseAppDetail.mockReturnValue({
data: {
id: 'test-app-id',
mode: AppModeEnum.CHAT,
} as App,
isLoading: false,
isPending: false,
error: null,
} as ReturnType<typeof useAppDetail>)
}
const renderConfigVar = (props: Partial<IConfigVarProps> = {}, debugOverrides: Partial<DebugConfigurationState> = {}) => {
const defaultProps: IConfigVarProps = {
promptVariables: [],
@@ -219,6 +242,7 @@ describe('ConfigVar', () => {
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
setupUseAppDetailMock()
})
it('should save updates when editing a basic variable', async () => {

View File

@@ -71,9 +71,10 @@ import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { PromptMode } from '@/models/debug'
import { fetchAppDetailDirect, updateAppModelConfig } from '@/service/apps'
import { updateAppModelConfig } from '@/service/apps'
import { fetchDatasets } from '@/service/datasets'
import { fetchCollectionList } from '@/service/tools'
import { useAppDetail } from '@/service/use-apps'
import { useFileUploadConfig } from '@/service/use-common'
import { AgentStrategy, AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } from '@/types/app'
import {
@@ -95,22 +96,22 @@ const Configuration: FC = () => {
const { notify } = useContext(ToastContext)
const { isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { appDetail, showAppConfigureFeaturesModal, setAppSidebarExpand, setShowAppConfigureFeaturesModal } = useAppStore(useShallow(state => ({
appDetail: state.appDetail,
const { showAppConfigureFeaturesModal, setAppSidebarExpand, setShowAppConfigureFeaturesModal } = useAppStore(useShallow(state => ({
setAppSidebarExpand: state.setAppSidebarExpand,
showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal,
setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal,
})))
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const pathname = usePathname()
const matched = pathname.match(/\/app\/([^/]+)/)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const { data: appDetail } = useAppDetail(appId)
const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail])
const [formattingChanged, setFormattingChanged] = useState(false)
const { setShowAccountSettingModal } = useModalContext()
const [hasFetchedDetail, setHasFetchedDetail] = useState(false)
const isLoading = !hasFetchedDetail
const pathname = usePathname()
const matched = pathname.match(/\/app\/([^/]+)/)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const [mode, setMode] = useState<AppModeEnum>(AppModeEnum.CHAT)
const [publishedConfig, setPublishedConfig] = useState<PublishConfig | null>(null)
@@ -548,7 +549,10 @@ const Configuration: FC = () => {
}, [modelConfig])
useEffect(() => {
(async () => {
if (!appDetail)
return
const initConfig = async () => {
const collectionList = await fetchCollectionList()
if (basePath) {
collectionList.forEach((item) => {
@@ -557,9 +561,8 @@ const Configuration: FC = () => {
})
}
setCollectionList(collectionList)
const res = await fetchAppDetailDirect({ url: '/apps', id: appId })
setMode(res.mode as AppModeEnum)
const modelConfig = res.model_config as BackendModelConfig
setMode(appDetail.mode as AppModeEnum)
const modelConfig = appDetail.model_config as BackendModelConfig
const promptMode = modelConfig.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple
doSetPromptMode(promptMode)
if (promptMode === PromptMode.advanced) {
@@ -669,7 +672,7 @@ const Configuration: FC = () => {
external_data_tools: modelConfig.external_data_tools ?? [],
system_parameters: modelConfig.system_parameters,
dataSets: datasets || [],
agentConfig: res.mode === AppModeEnum.AGENT_CHAT ? {
agentConfig: appDetail.mode === AppModeEnum.AGENT_CHAT ? {
max_iteration: DEFAULT_AGENT_SETTING.max_iteration,
...modelConfig.agent_mode,
// remove dataset
@@ -680,7 +683,7 @@ const Configuration: FC = () => {
const toolInCollectionList = collectionList.find(c => tool.provider_id === c.id)
return {
...tool,
isDeleted: res.deleted_tools?.some((deletedTool: any) => deletedTool.provider_id === tool.provider_id && deletedTool.tool_name === tool.tool_name) ?? false,
isDeleted: appDetail.deleted_tools?.some((deletedTool: any) => deletedTool.provider_id === tool.provider_id && deletedTool.tool_name === tool.tool_name) ?? false,
notAuthor: toolInCollectionList?.is_team_authorization === false,
...(tool.provider_type === 'builtin'
? {
@@ -726,8 +729,10 @@ const Configuration: FC = () => {
datasetConfigsToSet.retrieval_model = datasetConfigsToSet.retrieval_model ?? RETRIEVE_TYPE.multiWay
setDatasetConfigs(datasetConfigsToSet)
setHasFetchedDetail(true)
})()
}, [appId])
}
initConfig()
}, [appDetail])
const promptEmpty = (() => {
if (mode !== AppModeEnum.COMPLETION)

View File

@@ -1,16 +1,22 @@
import type { App, AppIconType } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import LogAnnotation from './index'
vi.mock('@/service/use-apps')
const mockUseAppDetail = vi.mocked(useAppDetail)
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
useParams: () => ({
appId: 'app-123',
}),
}))
vi.mock('@/app/components/app/annotation', () => ({
@@ -61,17 +67,25 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
...overrides,
})
function mockAppDetailReturn(app: App | undefined) {
mockUseAppDetail.mockReturnValue({
data: app,
isLoading: false,
error: null,
} as ReturnType<typeof useAppDetail>)
}
describe('LogAnnotation', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createMockApp() })
mockAppDetailReturn(createMockApp())
})
// Rendering behavior
describe('Rendering', () => {
it('should render loading state when app detail is missing', () => {
// Arrange
useAppStore.setState({ appDetail: undefined })
mockAppDetailReturn(undefined)
// Act
render(<LogAnnotation pageType={PageType.log} />)
@@ -82,7 +96,7 @@ describe('LogAnnotation', () => {
it('should render log and annotation tabs for non-completion apps', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.CHAT }))
// Act
render(<LogAnnotation pageType={PageType.log} />)
@@ -94,7 +108,7 @@ describe('LogAnnotation', () => {
it('should render only log tab for completion apps', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.COMPLETION }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.COMPLETION }))
// Act
render(<LogAnnotation pageType={PageType.log} />)
@@ -106,7 +120,7 @@ describe('LogAnnotation', () => {
it('should hide tabs and render workflow log in workflow mode', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.WORKFLOW }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.WORKFLOW }))
// Act
render(<LogAnnotation pageType={PageType.log} />)
@@ -121,7 +135,7 @@ describe('LogAnnotation', () => {
describe('Props', () => {
it('should render log content when page type is log', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.CHAT }))
// Act
render(<LogAnnotation pageType={PageType.log} />)
@@ -133,7 +147,7 @@ describe('LogAnnotation', () => {
it('should render annotation content when page type is annotation', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.CHAT }))
// Act
render(<LogAnnotation pageType={PageType.annotation} />)

View File

@@ -1,16 +1,16 @@
'use client'
import type { FC } from 'react'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Annotation from '@/app/components/app/annotation'
import Log from '@/app/components/app/log'
import { useStore as useAppStore } from '@/app/components/app/store'
import WorkflowLog from '@/app/components/app/workflow-log'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'
import Loading from '@/app/components/base/loading'
import TabSlider from '@/app/components/base/tab-slider-plain'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
@@ -23,7 +23,8 @@ const LogAnnotation: FC<Props> = ({
}) => {
const { t } = useTranslation()
const router = useRouter()
const appDetail = useAppStore(state => state.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const options = useMemo(() => {
if (appDetail?.mode === AppModeEnum.COMPLETION)

View File

@@ -19,7 +19,6 @@ import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppBasic from '@/app/components/app-sidebar/basic'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import CopyFeedback from '@/app/components/base/copy-feedback'
@@ -35,7 +34,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { useAppDetail, useInvalidateAppDetail } from '@/service/use-apps'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
@@ -73,11 +72,11 @@ function AppCard({
}: IAppCardProps) {
const router = useRouter()
const pathname = usePathname()
const invalidateAppDetail = useInvalidateAppDetail()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '')
const docLink = useDocLink()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const { data: appDetail } = useAppDetail(appInfo.id)
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [showEmbedded, setShowEmbedded] = useState(false)
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
@@ -178,16 +177,10 @@ function AppCard({
return
setShowAccessControl(true)
}, [appDetail])
const handleAccessControlUpdate = useCallback(async () => {
try {
const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail!.id })
setAppDetail(res)
setShowAccessControl(false)
}
catch (error) {
console.error('Failed to fetch app detail:', error)
}
}, [appDetail, setAppDetail])
const handleAccessControlUpdate = useCallback(() => {
invalidateAppDetail(appInfo.id)
setShowAccessControl(false)
}, [invalidateAppDetail, appInfo.id])
return (
<div

View File

@@ -1,9 +1,7 @@
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { App, AppSSO } from '@/types/app'
import { create } from 'zustand'
type State = {
appDetail?: App & Partial<AppSSO>
appSidebarExpand: string
currentLogItem?: IChatItem
currentLogModalActiveTab: string
@@ -14,7 +12,6 @@ type State = {
}
type Action = {
setAppDetail: (appDetail?: App & Partial<AppSSO>) => void
setAppSidebarExpand: (state: string) => void
setCurrentLogItem: (item?: IChatItem) => void
setCurrentLogModalActiveTab: (tab: string) => void
@@ -25,8 +22,6 @@ type Action = {
}
export const useStore = create<State & Action>(set => ({
appDetail: undefined,
setAppDetail: appDetail => set(() => ({ appDetail })),
appSidebarExpand: '',
setAppSidebarExpand: appSidebarExpand => set(() => ({ appSidebarExpand })),
currentLogItem: undefined,

View File

@@ -2,7 +2,6 @@ import type { App } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast'
import { Plan } from '@/app/components/billing/type'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@@ -16,9 +15,13 @@ vi.mock('next/navigation', () => ({
push: mockPush,
replace: mockReplace,
}),
useParams: () => ({ appId: 'app-123' }),
}))
// Use real store - global zustand mock will auto-reset between tests
const mockInvalidateAppDetail = vi.fn()
vi.mock('@/service/use-apps', () => ({
useInvalidateAppDetail: () => mockInvalidateAppDetail,
}))
const mockSwitchApp = vi.fn()
const mockDeleteApp = vi.fn()
@@ -135,17 +138,9 @@ const renderComponent = (overrides: Partial<React.ComponentProps<typeof SwitchAp
}
}
const setAppDetailSpy = vi.fn()
describe('SwitchAppModal', () => {
beforeEach(() => {
vi.clearAllMocks()
// Spy on setAppDetail
const originalSetAppDetail = useAppStore.getState().setAppDetail
setAppDetailSpy.mockImplementation((...args: Parameters<typeof originalSetAppDetail>) => {
originalSetAppDetail(...args)
})
useAppStore.setState({ setAppDetail: setAppDetailSpy as typeof originalSetAppDetail })
mockIsEditor = true
mockEnableBilling = false
mockPlan = {
@@ -281,7 +276,7 @@ describe('SwitchAppModal', () => {
})
expect(mockReplace).toHaveBeenCalledWith('/app/new-app-002/workflow')
expect(mockPush).not.toHaveBeenCalled()
expect(setAppDetailSpy).toHaveBeenCalledTimes(1)
expect(mockInvalidateAppDetail).toHaveBeenCalledWith('app-123')
})
it('should notify error when switch app fails', async () => {

View File

@@ -3,11 +3,10 @@
import type { App } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox'
@@ -21,6 +20,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { deleteApp, switchApp } from '@/service/apps'
import { useInvalidateAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
@@ -38,7 +38,8 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
const { push, replace } = useRouter()
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const setAppDetail = useAppStore(s => s.setAppDetail)
const { appId } = useParams()
const invalidateAppDetail = useInvalidateAppDetail()
const { isCurrentWorkspaceEditor } = useAppContext()
const { plan, enableBilling } = useProviderContext()
@@ -70,7 +71,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
onClose()
notify({ type: 'success', message: t('newApp.appCreated', { ns: 'app' }) })
if (inAppDetail)
setAppDetail()
invalidateAppDetail(appId as string)
if (removeOriginal)
await deleteApp(appDetail.id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')

View File

@@ -11,18 +11,24 @@
import type { App, AppIconType, AppModeEnum } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppDetail } from '@/service/use-apps'
import DetailPanel from './detail'
// ============================================================================
// Mocks
// ============================================================================
vi.mock('@/service/use-apps')
const mockUseAppDetail = vi.mocked(useAppDetail)
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
useParams: () => ({
appId: 'test-app-id',
}),
}))
// Mock the Run component as it has complex dependencies
@@ -88,6 +94,18 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
...overrides,
})
// ============================================================================
// Helper Functions
// ============================================================================
function mockAppDetailReturn(app: App | undefined) {
mockUseAppDetail.mockReturnValue({
data: app,
isLoading: false,
error: null,
} as ReturnType<typeof useAppDetail>)
}
// ============================================================================
// Tests
// ============================================================================
@@ -97,7 +115,7 @@ describe('DetailPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createMockApp() })
mockAppDetailReturn(createMockApp())
})
// --------------------------------------------------------------------------
@@ -125,7 +143,7 @@ describe('DetailPanel', () => {
})
it('should render Run component with correct URLs', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'app-456' }) })
mockAppDetailReturn(createMockApp({ id: 'app-456' }))
render(<DetailPanel runID="run-789" onClose={defaultOnClose} />)
@@ -185,7 +203,7 @@ describe('DetailPanel', () => {
it('should navigate to workflow page with replayRunId when replay button is clicked', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-replay-test' }) })
mockAppDetailReturn(createMockApp({ id: 'app-replay-test' }))
render(<DetailPanel runID="run-to-replay" onClose={defaultOnClose} canReplay={true} />)
@@ -197,7 +215,7 @@ describe('DetailPanel', () => {
it('should not navigate when replay clicked but appDetail is missing', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: undefined })
mockAppDetailReturn(undefined)
render(<DetailPanel runID="run-123" onClose={defaultOnClose} canReplay={true} />)
@@ -213,7 +231,7 @@ describe('DetailPanel', () => {
// --------------------------------------------------------------------------
describe('URL Generation', () => {
it('should generate correct run detail URL', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'my-app' }) })
mockAppDetailReturn(createMockApp({ id: 'my-app' }))
render(<DetailPanel runID="my-run" onClose={defaultOnClose} />)
@@ -221,7 +239,7 @@ describe('DetailPanel', () => {
})
it('should generate correct tracing list URL', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'my-app' }) })
mockAppDetailReturn(createMockApp({ id: 'my-app' }))
render(<DetailPanel runID="my-run" onClose={defaultOnClose} />)
@@ -229,7 +247,7 @@ describe('DetailPanel', () => {
})
it('should handle special characters in runID', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'app-id' }) })
mockAppDetailReturn(createMockApp({ id: 'app-id' }))
render(<DetailPanel runID="run-with-special-123" onClose={defaultOnClose} />)
@@ -242,7 +260,7 @@ describe('DetailPanel', () => {
// --------------------------------------------------------------------------
describe('Store Integration', () => {
it('should read appDetail from store', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'store-app-id' }) })
mockAppDetailReturn(createMockApp({ id: 'store-app-id' }))
render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
@@ -250,7 +268,7 @@ describe('DetailPanel', () => {
})
it('should handle undefined appDetail from store gracefully', () => {
useAppStore.setState({ appDetail: undefined })
mockAppDetailReturn(undefined)
render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
@@ -272,7 +290,7 @@ describe('DetailPanel', () => {
it('should handle very long runID', () => {
const longRunId = 'a'.repeat(100)
useAppStore.setState({ appDetail: createMockApp({ id: 'app-id' }) })
mockAppDetailReturn(createMockApp({ id: 'app-id' }))
render(<DetailPanel runID={longRunId} onClose={defaultOnClose} />)

View File

@@ -1,12 +1,12 @@
'use client'
import type { FC } from 'react'
import { RiCloseLine, RiPlayLargeLine } from '@remixicon/react'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/app/store'
import TooltipPlus from '@/app/components/base/tooltip'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import Run from '@/app/components/workflow/run'
import { useAppDetail } from '@/service/use-apps'
type ILogDetail = {
runID: string
@@ -16,7 +16,8 @@ type ILogDetail = {
const DetailPanel: FC<ILogDetail> = ({ runID, onClose, canReplay = false }) => {
const { t } = useTranslation()
const appDetail = useStore(state => state.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const router = useRouter()
const handleReplay = () => {

View File

@@ -34,6 +34,18 @@ import Logs from './index'
vi.mock('@/service/use-log')
vi.mock('@/service/use-apps', () => ({
useAppDetail: () => ({
data: {
id: 'test-app-id',
name: 'Test App',
mode: 'workflow',
},
isLoading: false,
error: null,
}),
}))
vi.mock('ahooks', () => ({
useDebounce: <T,>(value: T) => value,
useDebounceFn: (fn: (value: string) => void) => ({ run: fn }),
@@ -51,6 +63,9 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
useParams: () => ({
appId: 'test-app-id',
}),
}))
vi.mock('next/link', () => ({

View File

@@ -13,7 +13,6 @@ import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } fr
import type { App, AppIconType, AppModeEnum } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import WorkflowAppLogList from './list'
@@ -27,6 +26,9 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
useParams: () => ({
appId: 'test-app-id',
}),
}))
// Mock useTimestamp hook
@@ -86,6 +88,40 @@ vi.mock('ahooks', () => ({
},
}))
// Mock app detail data for useAppDetail hook
const mockAppDetail: App = {
id: 'test-app-id',
name: 'Test App',
description: 'Test app description',
author_name: 'Test Author',
icon_type: 'emoji' as AppIconType,
icon: '',
icon_background: '#FFEAD5',
icon_url: null,
use_icon_as_answer_icon: false,
mode: 'workflow' as AppModeEnum,
enable_site: true,
enable_api: true,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
created_at: Date.now(),
updated_at: Date.now(),
site: {
access_token: 'token',
app_base_url: 'https://example.com',
} as App['site'],
api_base_url: 'https://api.example.com',
tags: [],
access_mode: 'public_access' as App['access_mode'],
}
vi.mock('@/service/use-apps', () => ({
useAppDetail: () => ({ data: mockAppDetail, isPending: false }),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
@@ -168,7 +204,6 @@ describe('WorkflowAppLogList', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createMockApp() })
})
// --------------------------------------------------------------------------
@@ -426,7 +461,6 @@ describe('WorkflowAppLogList', () => {
describe('Drawer', () => {
it('should open drawer when clicking on a log row', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-123' }) })
const logs = createMockLogsResponse([
createMockWorkflowLog({
id: 'log-1',
@@ -449,7 +483,6 @@ describe('WorkflowAppLogList', () => {
it('should close drawer and call onRefresh when closing', async () => {
const user = userEvent.setup()
const onRefresh = vi.fn()
useAppStore.setState({ appDetail: createMockApp() })
const logs = createMockLogsResponse([createMockWorkflowLog()])
render(
@@ -498,7 +531,6 @@ describe('WorkflowAppLogList', () => {
describe('Replay Functionality', () => {
it('should allow replay when triggered from app-run', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-replay' }) })
const logs = createMockLogsResponse([
createMockWorkflowLog({
workflow_run: createMockWorkflowRun({
@@ -521,12 +553,11 @@ describe('WorkflowAppLogList', () => {
const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })
await user.click(replayButton)
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-replay/workflow?replayRunId=run-to-replay')
expect(mockRouterPush).toHaveBeenCalledWith('/app/test-app-id/workflow?replayRunId=run-to-replay')
})
it('should allow replay when triggered from debugging', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-debug' }) })
const logs = createMockLogsResponse([
createMockWorkflowLog({
workflow_run: createMockWorkflowRun({
@@ -552,7 +583,6 @@ describe('WorkflowAppLogList', () => {
it('should not show replay for webhook triggers', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-webhook' }) })
const logs = createMockLogsResponse([
createMockWorkflowLog({
workflow_run: createMockWorkflowRun({

View File

@@ -4,14 +4,15 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentIteration, AgentLogDetailResponse } from '@/models/log'
import { uniq } from 'es-toolkit/array'
import { flatten } from 'es-toolkit/compat'
import { useParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/toast'
import { fetchAgentLogDetail } from '@/service/log'
import { useAppDetail } from '@/service/use-apps'
import { cn } from '@/utils/classnames'
import ResultPanel from './result'
import TracingPanel from './tracing'
@@ -32,7 +33,8 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [currentTab, setCurrentTab] = useState<string>(activeTab)
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const [loading, setLoading] = useState<boolean>(true)
const [runDetail, setRunDetail] = useState<AgentLogDetailResponse>()
const [list, setList] = useState<AgentIteration[]>([])

View File

@@ -1,8 +1,8 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentLogDetailResponse } from '@/models/log'
import { useEffect, useRef } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { ToastProvider } from '@/app/components/base/toast'
import AgentLogModal from '.'
@@ -64,21 +64,34 @@ const MOCK_CHAT_ITEM: IChatItem = {
conversationId: 'conv-123',
}
const MOCK_APP_DETAIL = {
id: 'app-1',
name: 'Analytics Agent',
mode: 'agent-chat',
}
const createQueryClient = () => {
const client = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
},
},
})
client.setQueryData(['apps', 'detail', 'app-1'], MOCK_APP_DETAIL)
return client
}
const AgentLogModalDemo = ({
width = 960,
}: {
width?: number
}) => {
const originalFetchRef = useRef<typeof globalThis.fetch>(null)
const setAppDetail = useAppStore(state => state.setAppDetail)
const [queryClient] = useState(() => createQueryClient())
useEffect(() => {
setAppDetail({
id: 'app-1',
name: 'Analytics Agent',
mode: 'agent-chat',
} as any)
originalFetchRef.current = globalThis.fetch?.bind(globalThis)
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -104,22 +117,23 @@ const AgentLogModalDemo = ({
return () => {
if (originalFetchRef.current)
globalThis.fetch = originalFetchRef.current
setAppDetail(undefined)
}
}, [setAppDetail])
}, [])
return (
<ToastProvider>
<div className="relative min-h-[540px] w-full bg-background-default-subtle p-6">
<AgentLogModal
currentLogItem={MOCK_CHAT_ITEM}
width={width}
onCancel={() => {
console.log('Agent log modal closed')
}}
/>
</div>
</ToastProvider>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<div className="relative min-h-[540px] w-full bg-background-default-subtle p-6">
<AgentLogModal
currentLogItem={MOCK_CHAT_ITEM}
width={width}
onCancel={() => {
console.log('Agent log modal closed')
}}
/>
</div>
</ToastProvider>
</QueryClientProvider>
)
}

View File

@@ -2,8 +2,8 @@ import type { Meta, StoryObj } from '@storybook/nextjs'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { WorkflowRunDetailResponse } from '@/models/log'
import type { NodeTracing, NodeTracingListResponse } from '@/types/workflow'
import { useEffect } from 'react'
import { useStore } from '@/app/components/app/store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { BlockEnum } from '@/app/components/workflow/types'
import MessageLogModal from '.'
@@ -94,12 +94,24 @@ const mockCurrentLogItem: IChatItem = {
workflow_run_id: 'run-demo-1',
}
const useMessageLogMocks = () => {
useEffect(() => {
const store = useStore.getState()
store.setAppDetail(SAMPLE_APP_DETAIL)
const createQueryClient = () => {
const client = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
},
},
})
client.setQueryData(['apps', 'detail', 'app-demo-1'], SAMPLE_APP_DETAIL)
return client
}
const originalFetch = globalThis.fetch?.bind(globalThis) ?? null
const useMessageLogMocks = () => {
const originalFetchRef = useRef<typeof globalThis.fetch>(null)
useEffect(() => {
originalFetchRef.current = globalThis.fetch?.bind(globalThis) ?? null
const handle = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string'
@@ -122,8 +134,8 @@ const useMessageLogMocks = () => {
)
}
if (originalFetch)
return originalFetch(input, init)
if (originalFetchRef.current)
return originalFetchRef.current(input, init)
throw new Error(`Unmocked fetch call for ${url}`)
}
@@ -131,8 +143,8 @@ const useMessageLogMocks = () => {
globalThis.fetch = handle as typeof globalThis.fetch
return () => {
globalThis.fetch = originalFetch || globalThis.fetch
useStore.getState().setAppDetail(undefined)
if (originalFetchRef.current)
globalThis.fetch = originalFetchRef.current
}
}, [])
}
@@ -140,17 +152,21 @@ const useMessageLogMocks = () => {
type MessageLogModalProps = React.ComponentProps<typeof MessageLogModal>
const MessageLogPreview = (props: MessageLogModalProps) => {
const [queryClient] = useState(() => createQueryClient())
useMessageLogMocks()
return (
<div className="relative min-h-[640px] w-full bg-background-default-subtle p-6">
<WorkflowContextProvider>
<MessageLogModal
{...props}
currentLogItem={mockCurrentLogItem}
/>
</WorkflowContextProvider>
</div>
<QueryClientProvider client={queryClient}>
<div className="relative min-h-[640px] w-full bg-background-default-subtle p-6">
<WorkflowContextProvider>
<MessageLogModal
{...props}
currentLogItem={mockCurrentLogItem}
/>
</WorkflowContextProvider>
</div>
</QueryClientProvider>
)
}

View File

@@ -2,10 +2,11 @@ import type { FC } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { RiCloseLine } from '@remixicon/react'
import { useClickAway } from 'ahooks'
import { useParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/app/store'
import Run from '@/app/components/workflow/run'
import { useAppDetail } from '@/service/use-apps'
import { cn } from '@/utils/classnames'
type MessageLogModalProps = {
@@ -25,7 +26,8 @@ const MessageLogModal: FC<MessageLogModalProps> = ({
const { t } = useTranslation()
const ref = useRef(null)
const [mounted, setMounted] = useState(false)
const appDetail = useStore(state => state.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
useClickAway(() => {
if (mounted)

View File

@@ -1,17 +1,17 @@
'use client'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import ApiServer from '@/app/components/develop/ApiServer'
import Doc from '@/app/components/develop/doc'
import { useAppDetail } from '@/service/use-apps'
type IDevelopMainProps = {
appId: string
}
const DevelopMain = ({ appId }: IDevelopMainProps) => {
const appDetail = useAppStore(state => state.appDetail)
const { data: appDetail, isPending } = useAppDetail(appId)
if (!appDetail) {
if (isPending || !appDetail) {
return (
<div className="flex h-full items-center justify-center bg-background-default">
<Loading />

View File

@@ -6,16 +6,14 @@ import {
RiRobot2Line,
} from '@remixicon/react'
import { flatten } from 'es-toolkit/compat'
import { produce } from 'immer'
import { useParams } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateAppTemplateDialog from '@/app/components/app/create-app-dialog'
import CreateAppModal from '@/app/components/app/create-app-modal'
import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
import { useInfiniteAppList } from '@/service/use-apps'
import { useAppDetail, useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import Nav from '../nav'
@@ -23,11 +21,10 @@ const AppNav = () => {
const { t } = useTranslation()
const { appId } = useParams()
const { isCurrentWorkspaceEditor } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
const { data: appDetail } = useAppDetail(appId as string)
const [showNewAppDialog, setShowNewAppDialog] = useState(false)
const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [navItems, setNavItems] = useState<NavItem[]>([])
const {
data: appsData,
@@ -55,48 +52,34 @@ const AppNav = () => {
setShowCreateFromDSLModal(true)
}
useEffect(() => {
if (appsData) {
const appItems = flatten((appsData.pages ?? []).map(appData => appData.data))
const navItems = appItems.map((app) => {
const link = ((isCurrentWorkspaceEditor, app) => {
if (!isCurrentWorkspaceEditor) {
return `/app/${app.id}/overview`
}
else {
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
return `/app/${app.id}/workflow`
else
return `/app/${app.id}/configuration`
}
})(isCurrentWorkspaceEditor, app)
return {
id: app.id,
icon_type: app.icon_type,
icon: app.icon,
icon_background: app.icon_background,
icon_url: app.icon_url,
name: app.name,
mode: app.mode,
link,
}
})
setNavItems(navItems as any)
}
}, [appsData, isCurrentWorkspaceEditor, setNavItems])
const navItems = useMemo(() => {
if (!appsData)
return []
// update current app name
useEffect(() => {
if (appDetail) {
const newNavItems = produce(navItems, (draft: NavItem[]) => {
navItems.forEach((app, index) => {
if (app.id === appDetail.id)
draft[index].name = appDetail.name
})
})
setNavItems(newNavItems)
}
}, [appDetail, navItems])
const appItems = flatten((appsData.pages ?? []).map(appData => appData.data))
return appItems.map((app) => {
const link = (() => {
if (!isCurrentWorkspaceEditor)
return `/app/${app.id}/overview`
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
return `/app/${app.id}/workflow`
return `/app/${app.id}/configuration`
})()
return {
id: app.id,
icon_type: app.icon_type,
icon: app.icon,
icon_background: app.icon_background,
icon_url: app.icon_url,
name: app.id === appDetail?.id ? appDetail.name : app.name,
mode: app.mode,
link,
}
}) as NavItem[]
}, [appsData, isCurrentWorkspaceEditor, appDetail])
return (
<>

View File

@@ -5,7 +5,6 @@ import Link from 'next/link'
import { useSelectedLayoutSegment } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
import { cn } from '@/utils/classnames'
import NavSelector from './nav-selector'
@@ -33,7 +32,6 @@ const Nav = ({
isLoadingMore,
isApp,
}: INavProps) => {
const setAppDetail = useAppStore(state => state.setAppDetail)
const [hovered, setHovered] = useState(false)
const segment = useSelectedLayoutSegment()
const isActivated = Array.isArray(activeSegment) ? activeSegment.includes(segment!) : segment === activeSegment
@@ -47,12 +45,6 @@ const Nav = ({
>
<Link href={link}>
<div
onClick={(e) => {
// Don't clear state if opening in new tab/window
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
return
setAppDetail()
}}
className={cn('flex h-7 cursor-pointer items-center rounded-[10px] px-2.5', isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text', curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover')}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}

View File

@@ -10,7 +10,6 @@ import { debounce } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import { Fragment, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
@@ -42,7 +41,6 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
const { t } = useTranslation()
const router = useRouter()
const { isCurrentWorkspaceEditor } = useAppContext()
const setAppDetail = useAppStore(state => state.setAppDetail)
const handleScroll = useCallback(debounce((e) => {
if (typeof onLoadMore === 'function') {
@@ -84,7 +82,6 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
onClick={() => {
if (curNav?.id === nav.id)
return
setAppDetail()
router.push(nav.link)
}}
title={nav.name}

View File

@@ -1,7 +1,7 @@
import type { MarketplaceCollection } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import { act, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
// ================================
@@ -157,6 +157,45 @@ vi.mock('@/config', () => ({
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
}))
// Mock service/client - configurable mock for testing
const {
mockMarketplaceCollections,
mockMarketplaceCollectionPlugins,
mockMarketplaceSearchAdvanced,
} = vi.hoisted(() => {
const mockMarketplaceCollections = vi.fn(() => Promise.resolve({
data: {
collections: [
{ name: 'test-collection', label: 'Test Collection' },
],
},
}))
const mockMarketplaceCollectionPlugins = vi.fn(() => Promise.resolve({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
],
},
}))
const mockMarketplaceSearchAdvanced = vi.fn(() => Promise.resolve({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
],
bundles: [],
total: 1,
},
}))
return { mockMarketplaceCollections, mockMarketplaceCollectionPlugins, mockMarketplaceSearchAdvanced }
})
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: mockMarketplaceCollections,
collectionPlugins: mockMarketplaceCollectionPlugins,
searchAdvanced: mockMarketplaceSearchAdvanced,
},
}))
// Mock var utils
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string, _params?: Record<string, string | undefined>) => `https://marketplace.dify.ai${path}`,
@@ -199,7 +238,7 @@ vi.mock('@/i18n-config/language', () => ({
}))
// Mock global fetch for utils testing
const originalFetch = globalThis.fetch
const _originalFetch = globalThis.fetch
// Mock useTags hook
const mockTags = [
@@ -1477,25 +1516,33 @@ describe('flatMap Coverage', () => {
describe('Async Utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
globalThis.fetch = originalFetch
// Reset mocks to default behavior
mockMarketplaceCollections.mockImplementation(() => Promise.resolve({
data: {
collections: [
{ name: 'test-collection', label: 'Test Collection' },
],
},
}))
mockMarketplaceCollectionPlugins.mockImplementation(() => Promise.resolve({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
],
},
}))
})
describe('getMarketplacePluginsByCollectionId', () => {
it('should fetch plugins by collection id successfully', async () => {
const mockPlugins = [
{ type: 'plugin', org: 'test', name: 'plugin1' },
{ type: 'plugin', org: 'test', name: 'plugin2' },
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
{ type: 'plugin', org: 'test', name: 'plugin2', tags: [] },
]
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { plugins: mockPlugins } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
mockMarketplaceCollectionPlugins.mockResolvedValue({
data: { plugins: mockPlugins },
})
const { getMarketplacePluginsByCollectionId } = await import('./utils')
const result = await getMarketplacePluginsByCollectionId('test-collection', {
@@ -1504,12 +1551,12 @@ describe('Async Utils', () => {
type: 'plugin',
})
expect(globalThis.fetch).toHaveBeenCalled()
expect(mockMarketplaceCollectionPlugins).toHaveBeenCalled()
expect(result).toHaveLength(2)
})
it('should handle fetch error and return empty array', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
mockMarketplaceCollectionPlugins.mockRejectedValue(new Error('Network error'))
const { getMarketplacePluginsByCollectionId } = await import('./utils')
const result = await getMarketplacePluginsByCollectionId('test-collection')
@@ -1518,53 +1565,39 @@ describe('Async Utils', () => {
})
it('should pass abort signal when provided', async () => {
const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1' }]
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { plugins: mockPlugins } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1', tags: [] }]
mockMarketplaceCollectionPlugins.mockResolvedValue({
data: { plugins: mockPlugins },
})
const controller = new AbortController()
const { getMarketplacePluginsByCollectionId } = await import('./utils')
await getMarketplacePluginsByCollectionId('test-collection', {}, { signal: controller.signal })
// oRPC uses Request objects, so check that fetch was called with a Request containing the right URL
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(Request),
expect.any(Object),
// Check that collectionPlugins was called with the correct params including signal
expect(mockMarketplaceCollectionPlugins).toHaveBeenCalledWith(
expect.objectContaining({
params: { collectionId: 'test-collection' },
}),
expect.objectContaining({
signal: controller.signal,
}),
)
const call = vi.mocked(globalThis.fetch).mock.calls[0]
const request = call[0] as Request
expect(request.url).toContain('test-collection')
})
})
describe('getMarketplaceCollectionsAndPlugins', () => {
it('should fetch collections and plugins successfully', async () => {
const mockCollections = [
{ name: 'collection1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
{ name: 'collection1', label: 'Collection 1' },
]
const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1' }]
const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1', tags: [] }]
let callCount = 0
globalThis.fetch = vi.fn().mockImplementation(() => {
callCount++
if (callCount === 1) {
return Promise.resolve(
new Response(JSON.stringify({ data: { collections: mockCollections } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
}
return Promise.resolve(
new Response(JSON.stringify({ data: { plugins: mockPlugins } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
mockMarketplaceCollections.mockResolvedValue({
data: { collections: mockCollections },
})
mockMarketplaceCollectionPlugins.mockResolvedValue({
data: { plugins: mockPlugins },
})
const { getMarketplaceCollectionsAndPlugins } = await import('./utils')
@@ -1578,7 +1611,7 @@ describe('Async Utils', () => {
})
it('should handle fetch error and return empty data', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
mockMarketplaceCollections.mockRejectedValue(new Error('Network error'))
const { getMarketplaceCollectionsAndPlugins } = await import('./utils')
const result = await getMarketplaceCollectionsAndPlugins()
@@ -1588,12 +1621,9 @@ describe('Async Utils', () => {
})
it('should append condition and type to URL when provided', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { collections: [] } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
mockMarketplaceCollections.mockResolvedValue({
data: { collections: [] },
})
const { getMarketplaceCollectionsAndPlugins } = await import('./utils')
await getMarketplaceCollectionsAndPlugins({
@@ -1601,11 +1631,16 @@ describe('Async Utils', () => {
type: 'bundle',
})
// oRPC uses Request objects, so check that fetch was called with a Request containing the right URL
expect(globalThis.fetch).toHaveBeenCalled()
const call = vi.mocked(globalThis.fetch).mock.calls[0]
const request = call[0] as Request
expect(request.url).toContain('condition=category%3Dtool')
// Check that collections was called with the correct query params
expect(mockMarketplaceCollections).toHaveBeenCalledWith(
expect.objectContaining({
query: expect.objectContaining({
condition: 'category=tool',
type: 'bundle',
}),
}),
expect.any(Object),
)
})
})
})

View File

@@ -1,14 +1,16 @@
import type { ReactElement } from 'react'
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
import type { App } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast'
import { Plan } from '@/app/components/billing/type'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import FeaturesTrigger from './features-trigger'
vi.mock('next/navigation', () => ({
useParams: () => ({ appId: 'app-id' }),
}))
const mockUseIsChatMode = vi.fn()
const mockUseTheme = vi.fn()
const mockUseNodesReadOnly = vi.fn()
@@ -27,7 +29,7 @@ const mockPublishWorkflow = vi.fn()
const mockUpdatePublishedWorkflow = vi.fn()
const mockResetWorkflowVersionHistory = vi.fn()
const mockInvalidateAppTriggers = vi.fn()
const mockFetchAppDetail = vi.fn()
const mockInvalidateAppDetail = vi.fn()
const mockSetPublishedAt = vi.fn()
const mockSetLastPublishedHasUserInput = vi.fn()
@@ -126,16 +128,14 @@ vi.mock('@/service/use-tools', () => ({
useInvalidateAppTriggers: () => mockInvalidateAppTriggers,
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
vi.mock('@/service/use-apps', () => ({
useInvalidateAppDetail: () => mockInvalidateAppDetail,
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => mockUseTheme(),
}))
// Use real app store - global zustand mock will auto-reset between tests
const createProviderContext = ({
type = Plan.sandbox,
isFetchedPlan = true,
@@ -176,9 +176,6 @@ describe('FeaturesTrigger', () => {
mockUseProviderContext.mockReturnValue(createProviderContext({}))
mockUseNodes.mockReturnValue([])
mockUseEdges.mockReturnValue([])
// Set up app store state
useAppStore.setState({ appDetail: { id: 'app-id' } as unknown as App })
mockFetchAppDetail.mockResolvedValue({ id: 'app-id' })
mockPublishWorkflow.mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
})
@@ -422,8 +419,7 @@ describe('FeaturesTrigger', () => {
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' })
expect(useAppStore.getState().appDetail).toBeDefined()
expect(mockInvalidateAppDetail).toHaveBeenCalledWith('app-id')
})
})
@@ -444,23 +440,5 @@ describe('FeaturesTrigger', () => {
})
})
})
it('should log error when app detail refresh fails after publish', async () => {
// Arrange
const user = userEvent.setup()
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
mockFetchAppDetail.mockRejectedValue(new Error('fetch failed'))
renderWithToast(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
// Assert
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalled()
})
consoleErrorSpy.mockRestore()
})
})
})

View File

@@ -6,6 +6,7 @@ import type {
} from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { RiApps2AddLine } from '@remixicon/react'
import { useParams } from 'next/navigation'
import {
memo,
useCallback,
@@ -14,7 +15,6 @@ import {
import { useTranslation } from 'react-i18next'
import { useEdges } from 'reactflow'
import AppPublisher from '@/app/components/app/app-publisher'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import { useFeatures } from '@/app/components/base/features/hooks'
import { useToastContext } from '@/app/components/base/toast'
@@ -39,7 +39,7 @@ import {
} from '@/app/components/workflow/types'
import { useProviderContext } from '@/context/provider-context'
import useTheme from '@/hooks/use-theme'
import { fetchAppDetail } from '@/service/apps'
import { useInvalidateAppDetail } from '@/service/use-apps'
import { useInvalidateAppTriggers } from '@/service/use-tools'
import { useInvalidateAppWorkflow, usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow'
import { cn } from '@/utils/classnames'
@@ -49,9 +49,9 @@ const FeaturesTrigger = () => {
const { theme } = useTheme()
const isChatMode = useIsChatMode()
const workflowStore = useWorkflowStore()
const appDetail = useAppStore(s => s.appDetail)
const appID = appDetail?.id
const setAppDetail = useAppStore(s => s.setAppDetail)
const { appId } = useParams()
const appID = appId as string
const invalidateAppDetail = useInvalidateAppDetail()
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
const { plan, isFetchedPlan } = useProviderContext()
const publishedAt = useStore(s => s.publishedAt)
@@ -125,15 +125,9 @@ const FeaturesTrigger = () => {
setShowFeaturesPanel(!showFeaturesPanel)
}, [workflowStore, getNodesReadOnly])
const updateAppDetail = useCallback(async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appID! })
setAppDetail({ ...res })
}
catch (error) {
console.error(error)
}
}, [appID, setAppDetail])
const updateAppDetail = useCallback(() => {
invalidateAppDetail(appID)
}, [appID, invalidateAppDetail])
const { mutateAsync: publishWorkflow } = usePublishWorkflow()
// const { validateBeforeRun } = useWorkflowRunValidation()

View File

@@ -7,6 +7,7 @@ import { AppModeEnum } from '@/types/app'
import WorkflowHeader from './index'
const mockResetWorkflowVersionHistory = vi.fn()
const mockUseAppDetail = vi.fn()
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'app-id',
@@ -38,14 +39,19 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
...overrides,
})
// Helper to set up app store state
const setupAppStore = (overrides: Partial<App> = {}) => {
const setupAppDetail = (overrides: Partial<App> = {}) => {
const appDetail = createMockApp(overrides)
useAppStore.setState({ appDetail })
mockUseAppDetail.mockReturnValue({ data: appDetail })
return appDetail
}
// Use real store - global zustand mock will auto-reset between tests
vi.mock('next/navigation', () => ({
useParams: () => ({ appId: 'app-id' }),
}))
vi.mock('@/service/use-apps', () => ({
useAppDetail: (...args: unknown[]) => mockUseAppDetail(...args),
}))
vi.mock('@/app/components/workflow/header', () => ({
default: (props: HeaderProps) => {
@@ -80,7 +86,7 @@ vi.mock('@/service/use-workflow', () => ({
describe('WorkflowHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppStore()
setupAppDetail()
})
afterEach(() => {
@@ -103,7 +109,7 @@ describe('WorkflowHeader', () => {
describe('Props', () => {
it('should configure preview mode when app is in advanced chat mode', () => {
// Arrange
setupAppStore({ mode: AppModeEnum.ADVANCED_CHAT })
setupAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })
// Act
render(<WorkflowHeader />)
@@ -117,7 +123,7 @@ describe('WorkflowHeader', () => {
it('should configure run mode when app is not in advanced chat mode', () => {
// Arrange
setupAppStore({ mode: AppModeEnum.COMPLETION })
setupAppDetail({ mode: AppModeEnum.COMPLETION })
// Act
render(<WorkflowHeader />)

View File

@@ -1,23 +1,23 @@
import type { HeaderProps } from '@/app/components/workflow/header'
import { useParams } from 'next/navigation'
import {
memo,
useCallback,
useMemo,
} from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import Header from '@/app/components/workflow/header'
import { useAppDetail } from '@/service/use-apps'
import { useResetWorkflowVersionHistory } from '@/service/use-workflow'
import { useIsChatMode } from '../../hooks'
import ChatVariableTrigger from './chat-variable-trigger'
import FeaturesTrigger from './features-trigger'
const WorkflowHeader = () => {
const { appDetail, setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({
appDetail: state.appDetail,
setCurrentLogItem: state.setCurrentLogItem,
setShowMessageLogModal: state.setShowMessageLogModal,
})))
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const setCurrentLogItem = useAppStore(state => state.setCurrentLogItem)
const setShowMessageLogModal = useAppStore(state => state.setShowMessageLogModal)
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const isChatMode = useIsChatMode()

View File

@@ -1,5 +1,6 @@
import type { PanelProps } from '@/app/components/workflow/panel'
import dynamic from 'next/dynamic'
import { useParams } from 'next/navigation'
import {
memo,
useMemo,
@@ -8,6 +9,7 @@ import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import Panel from '@/app/components/workflow/panel'
import { useStore } from '@/app/components/workflow/store'
import { useAppDetail } from '@/service/use-apps'
import {
useIsChatMode,
} from '../hooks'
@@ -104,16 +106,16 @@ const WorkflowPanelOnRight = () => {
)
}
const WorkflowPanel = () => {
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const versionHistoryPanelProps = useMemo(() => {
const appId = appDetail?.id
return {
getVersionListUrl: `/apps/${appId}/workflows`,
deleteVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`,
updateVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`,
latestVersionId: appDetail?.workflow?.id,
}
}, [appDetail?.id, appDetail?.workflow?.id])
}, [appId, appDetail?.workflow?.id])
const panelProps: PanelProps = useMemo(() => {
return {

View File

@@ -1,15 +1,16 @@
import { useParams } from 'next/navigation'
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useToastContext } from '@/app/components/base/toast'
import {
DSL_EXPORT_CHECK,
} from '@/app/components/workflow/constants'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { exportAppConfig } from '@/service/apps'
import { useAppDetail } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
@@ -20,7 +21,8 @@ export const useDSL = () => {
const [exporting, setExporting] = useState(false)
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const handleExportDSL = useCallback(async (include = false, workflowId?: string) => {
if (!appDetail)

View File

@@ -1,8 +1,10 @@
import { useStore as useAppStore } from '@/app/components/app/store'
import { useParams } from 'next/navigation'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
export const useIsChatMode = () => {
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
return appDetail?.mode === AppModeEnum.ADVANCED_CHAT
}

View File

@@ -1,17 +1,18 @@
import type { Edge, Node } from '@/app/components/workflow/types'
import type { FileUploadConfigResponse } from '@/models/common'
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { useParams } from 'next/navigation'
import {
useCallback,
useEffect,
useState,
} from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import {
useStore,
useWorkflowStore,
} from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAppDetail } from '@/service/use-apps'
import { useWorkflowConfig } from '@/service/use-workflow'
import {
fetchNodesDefaultConfigs,
@@ -38,13 +39,16 @@ export const useWorkflowInit = () => {
nodes: nodesTemplate,
edges: edgesTemplate,
} = useWorkflowTemplate()
const appDetail = useAppStore(state => state.appDetail)!
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const setSyncWorkflowDraftHash = useStore(s => s.setSyncWorkflowDraftHash)
const [data, setData] = useState<FetchWorkflowDraftResponse>()
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
workflowStore.setState({ appId: appDetail.id, appName: appDetail.name })
}, [appDetail.id, workflowStore])
if (appDetail) {
workflowStore.setState({ appId: appDetail.id, appName: appDetail.name })
}
}, [appDetail, workflowStore])
const handleUpdateWorkflowFileUploadConfig = useCallback((config: FileUploadConfigResponse) => {
const { setFileUploadConfig } = workflowStore.getState()
@@ -56,6 +60,8 @@ export const useWorkflowInit = () => {
} = useWorkflowConfig('/files/upload', handleUpdateWorkflowFileUploadConfig)
const handleGetInitialWorkflowData = useCallback(async () => {
if (!appDetail)
return
try {
const res = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`)
setData(res)
@@ -109,8 +115,9 @@ export const useWorkflowInit = () => {
}, [appDetail, nodesTemplate, edgesTemplate, workflowStore, setSyncWorkflowDraftHash])
useEffect(() => {
handleGetInitialWorkflowData()
}, [])
if (appDetail)
handleGetInitialWorkflowData()
}, [appDetail, handleGetInitialWorkflowData])
const handleFetchPreloadData = useCallback(async () => {
try {

View File

@@ -4,14 +4,13 @@ import type { IOtherOptions } from '@/service/base'
import type { VersionHistory } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import { usePathname } from 'next/navigation'
import { useParams, usePathname } from 'next/navigation'
import { useCallback, useRef } from 'react'
import {
useReactFlow,
useStoreApi,
} from 'reactflow'
import { v4 as uuidV4 } from 'uuid'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
@@ -23,6 +22,7 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { handleStream, post, ssePost } from '@/service/base'
import { ContentType } from '@/service/fetch'
import { useAppDetail } from '@/service/use-apps'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { stopWorkflowRun } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
@@ -63,6 +63,8 @@ export const useWorkflowRun = () => {
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
const pathname = usePathname()
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const configsMap = useConfigsMap()
const { flowId, flowType } = configsMap
const invalidAllLastRun = useInvalidAllLastRun(flowType, flowId)
@@ -180,7 +182,6 @@ export const useWorkflowRun = () => {
...restCallback
} = callback || {}
workflowStore.setState({ historyWorkflowData: undefined })
const appDetail = useAppStore.getState().appDetail
const workflowContainer = document.getElementById('workflow-container')
const {
@@ -667,7 +668,7 @@ export const useWorkflowRun = () => {
},
},
)
}, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace])
}, [store, doSyncWorkflowDraft, workflowStore, pathname, appDetail, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace])
const handleStopRun = useCallback((taskId: string) => {
const setStoppedState = () => {
@@ -696,7 +697,6 @@ export const useWorkflowRun = () => {
}
if (taskId) {
const appId = useAppStore.getState().appDetail?.id
stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`)
setStoppedState()
return
@@ -725,7 +725,7 @@ export const useWorkflowRun = () => {
abortControllerRef.current = null
setStoppedState()
}, [workflowStore])
}, [workflowStore, appId])
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))

View File

@@ -2,12 +2,11 @@
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store'
import { useSearchParams } from 'next/navigation'
import { useParams, useSearchParams } from 'next/navigation'
import {
useEffect,
useMemo,
} from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { FeaturesProvider } from '@/app/components/base/features'
import Loading from '@/app/components/base/loading'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
@@ -26,6 +25,7 @@ import {
} from '@/app/components/workflow/utils'
import { useAppContext } from '@/context/app-context'
import { fetchRunDetail } from '@/service/log'
import { useAppDetail } from '@/service/use-apps'
import { useAppTriggers } from '@/service/use-tools'
import { AppModeEnum } from '@/types/app'
import WorkflowAppMain from './components/workflow-main'
@@ -47,10 +47,10 @@ const WorkflowAppWithAdditionalContext = () => {
// Initialize trigger status at application level
const { setTriggerStatuses } = useTriggerStatusStore()
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW
const { data: triggersResponse } = useAppTriggers(isWorkflowMode ? appId : undefined, {
const { data: triggersResponse } = useAppTriggers(isWorkflowMode ? appId as string : undefined, {
staleTime: 5 * 60 * 1000, // 5 minutes cache
refetchOnWindowFocus: false,
})

View File

@@ -44,7 +44,6 @@ const ViewWorkflowHistory = () => {
const { nodesReadOnly } = useNodesReadOnly()
const { setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({
appDetail: state.appDetail,
setCurrentLogItem: state.setCurrentLogItem,
setShowMessageLogModal: state.setShowMessageLogModal,
})))

View File

@@ -1,15 +1,15 @@
import { produce } from 'immer'
import { useParams } from 'next/navigation'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { fetchWebhookUrl } from '@/service/apps'
export const useAutoGenerateWebhookUrl = () => {
const reactFlowStore = useStoreApi()
const { appId } = useParams()
return useCallback(async (nodeId: string) => {
const appId = useAppStore.getState().appDetail?.id
if (!appId)
return
@@ -22,7 +22,7 @@ export const useAutoGenerateWebhookUrl = () => {
return
try {
const response = await fetchWebhookUrl({ appId, nodeId })
const response = await fetchWebhookUrl({ appId: appId as string, nodeId })
const { getNodes: getLatestNodes, setNodes } = reactFlowStore.getState()
let hasUpdated = false
const updatedNodes = produce(getLatestNodes(), (draft) => {
@@ -44,5 +44,5 @@ export const useAutoGenerateWebhookUrl = () => {
catch (error: unknown) {
console.error('Failed to auto-generate webhook URL:', error)
}
}, [reactFlowStore])
}, [reactFlowStore, appId])
}

View File

@@ -14,6 +14,7 @@ import type {
import type { Emoji } from '@/app/components/tools/types'
import type { DataSet } from '@/models/datasets'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import { useParams } from 'next/navigation'
import {
useCallback,
useMemo,
@@ -21,7 +22,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useEdges, useStoreApi } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useToastContext } from '@/app/components/base/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
@@ -29,6 +29,7 @@ import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { MAX_TREE_DEPTH } from '@/config'
import { useGetLanguage } from '@/context/i18n'
import { fetchDatasets } from '@/service/datasets'
import { useAppDetail } from '@/service/use-apps'
import { useStrategyProviders } from '@/service/use-strategy'
import {
useAllBuiltInTools,
@@ -96,7 +97,9 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
const { data: triggerPlugins } = useAllTriggerPlugins()
const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail)
const getToolIcon = useGetToolIcon()
const appMode = useAppStore.getState().appDetail?.mode
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const appMode = appDetail?.mode
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
const map = useNodesAvailableVarList(nodes)
@@ -249,7 +252,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
})
return list
}, [nodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, getCheckData, t, map, shouldCheckStartNode])
}, [nodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, triggerPlugins, getCheckData, t, map, shouldCheckStartNode])
return needWarningNodes
}
@@ -270,7 +273,9 @@ export const useChecklistBeforePublish = () => {
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const appMode = useAppStore.getState().appDetail?.mode
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const appMode = appDetail?.mode
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
const getCheckData = useCallback((data: CommonNodeType<{}>, datasets: DataSet[]) => {
@@ -419,7 +424,7 @@ export const useChecklistBeforePublish = () => {
}
return true
}, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, workflowStore, buildInTools, customTools, workflowTools, shouldCheckStartNode])
}, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, workflowStore, buildInTools, customTools, workflowTools, shouldCheckStartNode, getNodesAvailableVarList])
return {
handleCheckBeforePublish,

View File

@@ -10,6 +10,7 @@ import type {
ValueSelector,
} from '../types'
import { uniqBy } from 'es-toolkit/compat'
import { useParams } from 'next/navigation'
import {
useCallback,
} from 'react'
@@ -18,9 +19,9 @@ import {
getOutgoers,
useStoreApi,
} from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { useNodesMetaData } from '.'
import {
@@ -43,7 +44,8 @@ import {
import { useAvailableBlocks } from './use-available-blocks'
export const useIsChatMode = () => {
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
return appDetail?.mode === AppModeEnum.ADVANCED_CHAT
}

View File

@@ -7,6 +7,7 @@ import {
RiPlayLargeLine,
} from '@remixicon/react'
import { debounce } from 'es-toolkit/compat'
import { useParams } from 'next/navigation'
import * as React from 'react'
import {
cloneElement,
@@ -60,6 +61,7 @@ import {
isSupportCustomRunForm,
} from '@/app/components/workflow/utils'
import { useModalContext } from '@/context/modal-context'
import { useAppDetail } from '@/service/use-apps'
import { useAllBuiltInTools } from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { FlowType } from '@/types/common'
@@ -109,6 +111,8 @@ const BasePanel: FC<BasePanelProps> = ({
}) => {
const { t } = useTranslation()
const language = useLanguage()
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const { showMessageLogModal } = useAppStore(useShallow(state => ({
showMessageLogModal: state.showMessageLogModal,
})))
@@ -196,7 +200,6 @@ const BasePanel: FC<BasePanelProps> = ({
const isChildNode = !!(data.isInIteration || data.isInLoop)
const isSupportSingleRun = canRunBySingle(data.type, isChildNode)
const appDetail = useAppStore(state => state.appDetail)
const hasClickRunning = useRef(false)
const [isPaused, setIsPaused] = useState(false)

View File

@@ -1,10 +1,10 @@
import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types'
import type { Variable } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { useParams } from 'next/navigation'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Toast from '@/app/components/base/toast'
import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
@@ -17,7 +17,7 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
const { t } = useTranslation()
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const { inputs, setInputs } = useNodeCrud<WebhookTriggerNodeType>(id, payload)
const appId = useAppStore.getState().appDetail?.id
const { appId } = useParams()
const { isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow()
const handleMethodChange = useCallback((method: HttpMethod) => {
@@ -217,7 +217,7 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
try {
// Call backend to generate or fetch webhook url for this node
const response = await fetchWebhookUrl({ appId, nodeId: id })
const response = await fetchWebhookUrl({ appId: appId as string, nodeId: id })
const newInputs = produce(inputs, (draft) => {
draft.webhook_url = response.webhook_url

View File

@@ -1,18 +1,19 @@
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { ChatItem, ChatItemInTree } from '@/app/components/base/chat/types'
import { RiCloseLine } from '@remixicon/react'
import { useParams } from 'next/navigation'
import {
memo,
useCallback,
useEffect,
useState,
} from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import Chat from '@/app/components/base/chat/chat'
import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import Loading from '@/app/components/base/loading'
import { fetchConversationMessages } from '@/service/debug'
import { useAppDetail } from '@/service/use-apps'
import { useWorkflowRun } from '../../hooks'
import {
useStore,
@@ -51,7 +52,8 @@ const ChatRecord = () => {
const [fetched, setFetched] = useState(false)
const [chatItemTree, setChatItemTree] = useState<ChatItemInTree[]>([])
const [threadChatItems, setThreadChatItems] = useState<IChatItem[]>([])
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const workflowStore = useWorkflowStore()
const { handleLoadBackupDraft } = useWorkflowRun()
const historyWorkflowData = useStore(s => s.historyWorkflowData)

View File

@@ -2,9 +2,9 @@ import type { StartNodeType } from '../../nodes/start/types'
import type { ChatWrapperRefType } from './index'
import type { ChatItem, OnSend } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { useParams } from 'next/navigation'
import { memo, useCallback, useEffect, useImperativeHandle, useMemo } from 'react'
import { useNodes } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import Chat from '@/app/components/base/chat/chat'
import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils'
import { useFeatures } from '@/app/components/base/features/hooks'
@@ -14,6 +14,7 @@ import {
fetchSuggestedQuestions,
stopChatMessageResponding,
} from '@/service/debug'
import { useAppDetail } from '@/service/use-apps'
import {
useStore,
useWorkflowStore,
@@ -45,7 +46,8 @@ const ChatWrapper = (
const nodes = useNodes<StartNodeType>()
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const startVariables = startNode?.data.variables
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const workflowStore = useWorkflowStore()
const inputs = useStore(s => s.inputs)
const setInputs = useStore(s => s.setInputs)

View File

@@ -11,6 +11,7 @@ import {
RiFileDownloadLine,
} from '@remixicon/react'
import { load as yamlLoad } from 'js-yaml'
import { useParams } from 'next/navigation'
import {
memo,
useCallback,
@@ -20,7 +21,6 @@ import {
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
@@ -35,6 +35,7 @@ import {
importDSL,
importDSLConfirm,
} from '@/service/apps'
import { useAppDetail } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { WORKFLOW_DATA_UPDATE } from './constants'
@@ -60,7 +61,8 @@ const UpdateDSLModal = ({
}: UpdateDSLModalProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const [currentFile, setDSLFile] = useState<File>()
const [fileContent, setFileContent] = useState<string>()
const [loading, setLoading] = useState(false)

View File

@@ -73,14 +73,6 @@
"count": 1
}
},
"app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx": {
"no-console": {
"count": 19
@@ -245,9 +237,6 @@
}
},
"app/components/app/app-publisher/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
},
"ts/no-explicit-any": {
"count": 6
}
@@ -656,9 +645,6 @@
"app/components/base/agent-log-modal/index.stories.tsx": {
"no-console": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/agent-log-modal/index.tsx": {
@@ -2187,14 +2173,6 @@
"count": 4
}
},
"app/components/header/app-nav/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/header/header-wrapper.tsx": {
"ts/no-explicit-any": {
"count": 1

View File

@@ -94,6 +94,17 @@ export const useAppDetail = (appID: string) => {
})
}
export const useInvalidateAppDetail = () => {
const queryClient = useQueryClient()
return (appId?: string) => {
if (!appId)
return
queryClient.invalidateQueries({
queryKey: [NAME_SPACE, 'detail', appId],
})
}
}
export const useAppList = (params: AppListParams, options?: { enabled?: boolean }) => {
const normalizedParams = normalizeAppListParams(params)
return useQuery<AppListResponse>({