refactor: migrate workflow queries to contracts (#35799)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-05-05 22:53:38 +08:00 committed by GitHub
parent c0431ec843
commit 995c43f3dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1013 additions and 593 deletions

View File

@ -1,4 +1,5 @@
import logging
import re
import uuid
from datetime import datetime
from typing import Any, Literal
@ -8,6 +9,7 @@ from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, computed_field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.datastructures import MultiDict
from werkzeug.exceptions import BadRequest
from controllers.common.helpers import FileInfo
@ -57,6 +59,7 @@ ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "co
register_enum_models(console_ns, IconType)
_logger = logging.getLogger(__name__)
_TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$")
class AppListQuery(BaseModel):
@ -66,22 +69,19 @@ class AppListQuery(BaseModel):
default="all", description="App mode filter"
)
name: str | None = Field(default=None, description="Filter by app name")
tag_ids: list[str] | None = Field(default=None, description="Comma-separated tag IDs")
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
is_created_by_me: bool | None = Field(default=None, description="Filter by creator")
@field_validator("tag_ids", mode="before")
@classmethod
def validate_tag_ids(cls, value: str | list[str] | None) -> list[str] | None:
def validate_tag_ids(cls, value: list[str] | None) -> list[str] | None:
if not value:
return None
if isinstance(value, str):
items = [item.strip() for item in value.split(",") if item.strip()]
elif isinstance(value, list):
items = [str(item).strip() for item in value if item and str(item).strip()]
else:
raise TypeError("Unsupported tag_ids type.")
if not isinstance(value, list):
raise ValueError("Unsupported tag_ids type.")
items = [str(item).strip() for item in value if item and str(item).strip()]
if not items:
return None
@ -91,6 +91,26 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in tag_ids.") from exc
def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
normalized: dict[str, str | list[str]] = {}
indexed_tag_ids: list[tuple[int, str]] = []
for key in query_args:
match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key)
if match:
indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
continue
value = query_args.get(key)
if value is not None:
normalized[key] = value
if indexed_tag_ids:
normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)]
return normalized
class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
@ -455,7 +475,7 @@ class AppListApi(Resource):
"""Get app list"""
current_user, current_tenant_id = current_account_with_tenant()
args = AppListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args))
args_dict = args.model_dump()
# get app list

View File

@ -10,6 +10,8 @@ from typing import Any
import pytest
from flask.views import MethodView
from pydantic import ValidationError
from werkzeug.datastructures import MultiDict
# kombu references MethodView as a global when importing celery/kombu pools.
if not hasattr(builtins, "MethodView"):
@ -174,6 +176,101 @@ def _dummy_workflow():
)
def test_app_list_query_normalizes_orpc_bracket_tag_ids(app_module):
first_tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08"
second_tag_id = "3c39395b-6d1f-4030-8b17-eaa7cc85221c"
query_args = MultiDict(
[
("page", "1"),
("limit", "30"),
("tag_ids[1]", second_tag_id),
("tag_ids[0]", first_tag_id),
]
)
normalized = app_module._normalize_app_list_query_args(query_args)
query = app_module.AppListQuery.model_validate(normalized)
assert query.tag_ids == [first_tag_id, second_tag_id]
def test_app_list_query_preserves_regular_query_params(app_module):
query_args = MultiDict(
[
("page", "2"),
("limit", "50"),
("mode", "chat"),
("name", "Sales Copilot"),
("is_created_by_me", "true"),
]
)
normalized = app_module._normalize_app_list_query_args(query_args)
query = app_module.AppListQuery.model_validate(normalized)
assert normalized == {
"page": "2",
"limit": "50",
"mode": "chat",
"name": "Sales Copilot",
"is_created_by_me": "true",
}
assert query.page == 2
assert query.limit == 50
assert query.mode == "chat"
assert query.name == "Sales Copilot"
assert query.is_created_by_me is True
assert query.tag_ids is None
def test_app_list_query_normalizes_empty_bracket_tag_ids_to_none(app_module):
query_args = MultiDict(
[
("tag_ids[0]", ""),
("tag_ids[1]", " "),
]
)
normalized = app_module._normalize_app_list_query_args(query_args)
query = app_module.AppListQuery.model_validate(normalized)
assert normalized == {"tag_ids": ["", " "]}
assert query.tag_ids is None
def test_app_list_query_rejects_invalid_bracket_tag_id(app_module):
normalized = app_module._normalize_app_list_query_args(MultiDict([("tag_ids[0]", "not-a-uuid")]))
with pytest.raises(ValidationError):
app_module.AppListQuery.model_validate(normalized)
def test_app_list_query_sorts_bracket_tag_ids_by_index(app_module):
first_tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08"
second_tag_id = "3c39395b-6d1f-4030-8b17-eaa7cc85221c"
third_tag_id = "9d5ec0f7-4f2b-4e7f-9c13-1e7a034d0eb1"
query_args = MultiDict(
[
("tag_ids[2]", third_tag_id),
("tag_ids[1]", second_tag_id),
("tag_ids[0]", first_tag_id),
]
)
normalized = app_module._normalize_app_list_query_args(query_args)
query = app_module.AppListQuery.model_validate(normalized)
assert query.tag_ids == [first_tag_id, second_tag_id, third_tag_id]
def test_app_list_query_rejects_flat_tag_ids(app_module):
tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08"
normalized = app_module._normalize_app_list_query_args(MultiDict([("tag_ids", tag_id)]))
with pytest.raises(ValidationError):
app_module.AppListQuery.model_validate(normalized)
def test_app_partial_serialization_uses_aliases(app_models):
AppPartial = app_models.AppPartial
created_at = _ts()

View File

@ -162,11 +162,6 @@
"count": 5
}
},
"web/app/account/(commonLayout)/account-page/index.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/account/(commonLayout)/delete-account/components/feed-back.tsx": {
"no-restricted-imports": {
"count": 1
@ -653,14 +648,6 @@
"count": 2
}
},
"web/app/components/apps/list.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
},
"react/unsupported-syntax": {
"count": 2
}
},
"web/app/components/apps/new-app-card.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -2824,14 +2811,6 @@
"count": 4
}
},
"web/app/components/header/app-nav/index.tsx": {
"react/set-state-in-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/header/header-wrapper.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -5480,11 +5459,6 @@
"count": 2
}
},
"web/service/use-apps.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/service/use-common.ts": {
"ts/no-empty-object-type": {
"count": 1

View File

@ -88,27 +88,36 @@ vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/apps', () => ({
fetchWorkflowOnlineUsers: vi.fn().mockResolvedValue({}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useInfiniteQuery: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
isFetching: mockIsFetching,
isFetchingNextPage: mockIsFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockHasNextPage,
error: mockError,
refetch: mockRefetch,
}),
}
})
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
isFetching: mockIsFetching,
isFetchingNextPage: mockIsFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockHasNextPage,
error: mockError,
refetch: mockRefetch,
}),
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/app/components/apps/hooks/use-workflow-online-users', () => ({
useWorkflowOnlineUsers: () => ({
onlineUsersMap: {},
}),
}))
vi.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))

View File

@ -75,27 +75,36 @@ vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/apps', () => ({
fetchWorkflowOnlineUsers: vi.fn().mockResolvedValue({}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useInfiniteQuery: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
isFetching: mockIsFetching,
isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
error: null,
refetch: mockRefetch,
}),
}
})
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
isFetching: mockIsFetching,
isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
error: null,
refetch: mockRefetch,
}),
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/app/components/apps/hooks/use-workflow-online-users', () => ({
useWorkflowOnlineUsers: () => ({
onlineUsersMap: {},
}),
}))
vi.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))

View File

@ -7,7 +7,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import {
RiGraduationCapFill,
} from '@remixicon/react'
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
@ -16,9 +16,9 @@ import PremiumBadge from '@/app/components/base/premium-badge'
import Collapse from '@/app/components/header/account-setting/collapse'
import { IS_CE_EDITION, validPassword } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import { updateUserProfile } from '@/service/common'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useAppList } from '@/service/use-apps'
import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common'
import DeleteAccount from '../delete-account'
@ -35,7 +35,15 @@ const descriptionClassName = `
export default function AccountPage() {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: appList } = useAppList({ page: 1, limit: 100, name: '' })
const { data: appList } = useQuery(consoleQuery.apps.list.queryOptions({
input: {
query: {
page: 1,
limit: 100,
name: '',
},
},
}))
const apps = appList?.data || []
const queryClient = useQueryClient()
// Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously.
@ -129,7 +137,7 @@ export default function AccountPage() {
}
const renderAppItem = (item: IItem) => {
const { icon, icon_background, icon_type, icon_url } = item as any
const { icon, icon_background, icon_type, icon_url } = item as IItem & Pick<App, 'icon' | 'icon_background' | 'icon_type' | 'icon_url'>
return (
<div className="flex px-3 py-1">
<div className="mr-3">

View File

@ -7,6 +7,11 @@ import { AppModeEnum } from '@/types/app'
import List from '../list'
const mockAppListInfiniteOptions = vi.hoisted(() => vi.fn((options: unknown) => options))
const mockUseWorkflowOnlineUsers = vi.hoisted(() => vi.fn((_options: unknown) => ({
onlineUsersMap: {},
})))
const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace }
vi.mock('@/next/navigation', () => ({
@ -14,6 +19,22 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: () => new URLSearchParams(''),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
systemFeatures: vi.fn(),
},
consoleQuery: {
apps: {
list: {
infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options),
},
},
systemFeatures: {
queryKey: () => ['console', 'systemFeatures'],
},
},
}))
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
vi.mock('@/context/app-context', () => ({
@ -45,12 +66,17 @@ vi.mock('../hooks/use-dsl-drag-drop', () => ({
},
}))
vi.mock('../hooks/use-workflow-online-users', () => ({
useWorkflowOnlineUsers: (options: unknown) => mockUseWorkflowOnlineUsers(options),
}))
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
const mockServiceState = {
error: null as Error | null,
hasNextPage: false,
isFetching: false,
isLoading: false,
isFetchingNextPage: false,
}
@ -89,16 +115,24 @@ const defaultAppData = {
}],
}
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useInfiniteQuery: () => ({
data: defaultAppData,
isLoading: mockServiceState.isLoading,
isFetching: mockServiceState.isFetching,
isFetchingNextPage: mockServiceState.isFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockServiceState.hasNextPage,
error: mockServiceState.error,
refetch: mockRefetch,
}),
}
})
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: defaultAppData,
isLoading: mockServiceState.isLoading,
isFetchingNextPage: mockServiceState.isFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockServiceState.hasNextPage,
error: mockServiceState.error,
refetch: mockRefetch,
}),
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
@ -194,6 +228,11 @@ const renderList = (searchParams = '') => {
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
}
type AppListInfiniteOptions = {
input: (pageParam: number) => { query: Record<string, unknown> }
getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined
}
describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -212,6 +251,7 @@ describe('List', () => {
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockUseWorkflowOnlineUsers.mockClear()
intersectionCallback = null
localStorage.clear()
})
@ -269,6 +309,15 @@ describe('List', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp'))!.toBeInTheDocument()
})
it('should pass workflow app ids to online users hook', () => {
renderList()
expect(mockUseWorkflowOnlineUsers).toHaveBeenCalledWith({
appIds: ['app-2'],
enabled: expect.any(Boolean),
})
})
})
describe('Tab Navigation', () => {
@ -323,6 +372,31 @@ describe('List', () => {
})
})
describe('App List Query', () => {
it('should build paged query input from active filters', () => {
mockQueryState.tagIDs = ['tag-1']
mockQueryState.keywords = 'sales'
mockQueryState.isCreatedByMe = true
renderList('?category=workflow')
const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions
expect(options.input(2)).toEqual({
query: {
page: 2,
limit: 30,
name: 'sales',
tag_ids: ['tag-1'],
is_created_by_me: true,
mode: AppModeEnum.WORKFLOW,
},
})
expect(options.getNextPageParam({ has_more: true, page: 2 })).toBe(3)
expect(options.getNextPageParam({ has_more: false, page: 2 })).toBeUndefined()
})
})
describe('Tag Filter', () => {
it('should render tag filter component', () => {
renderList()

View File

@ -0,0 +1,49 @@
import type { WorkflowOnlineUser, WorkflowOnlineUsersResponse } from '@/models/app'
import { skipToken, useQuery } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
type WorkflowOnlineUsersMap = Record<string, WorkflowOnlineUser[]>
type UseWorkflowOnlineUsersParams = {
appIds: string[]
enabled: boolean
}
const normalizeWorkflowOnlineUsers = (response?: WorkflowOnlineUsersResponse): WorkflowOnlineUsersMap => {
const data = response?.data
if (!data)
return {}
if (Array.isArray(data)) {
return data.reduce<WorkflowOnlineUsersMap>((acc, item) => {
if (item?.app_id)
acc[item.app_id] = item.users || []
return acc
}, {})
}
return Object.entries(data).reduce<WorkflowOnlineUsersMap>((acc, [appId, users]) => {
if (appId)
acc[appId] = users || []
return acc
}, {})
}
export const useWorkflowOnlineUsers = ({
appIds,
enabled,
}: UseWorkflowOnlineUsersParams) => {
const shouldFetch = enabled && appIds.length > 0
const { data: onlineUsersMap = {} } = useQuery(consoleQuery.apps.workflowOnlineUsers.queryOptions({
input: shouldFetch
? { body: { app_ids: appIds } }
: skipToken,
select: normalizeWorkflowOnlineUsers,
refetchInterval: shouldFetch ? 10000 : false,
}))
return {
onlineUsersMap,
}
}

View File

@ -1,9 +1,9 @@
'use client'
import type { FC } from 'react'
import type { WorkflowOnlineUser } from '@/models/app'
import type { AppListQuery } from '@/contract/console/apps'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -17,9 +17,8 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import { fetchWorkflowOnlineUsers } from '@/service/apps'
import { consoleQuery } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum, AppModes } from '@/types/app'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
@ -27,6 +26,7 @@ import Empty from './empty'
import Footer from './footer'
import useAppsQueryState from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
import NewAppCard from './new-app-card'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
@ -71,7 +71,6 @@ const List: FC<Props> = ({
const containerRef = useRef<HTMLDivElement>(null)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const [workflowOnlineUsersMap, setWorkflowOnlineUsersMap] = useState<Record<string, WorkflowOnlineUser[]>>({})
const setKeywords = useCallback((keywords: string) => {
setQuery(prev => ({ ...prev, keywords }))
}, [setQuery])
@ -90,14 +89,14 @@ const List: FC<Props> = ({
enabled: isCurrentWorkspaceEditor,
})
const appListQueryParams = {
const appListQuery = useMemo<AppListQuery>(() => ({
page: 1,
limit: 30,
name: searchKeywords,
tag_ids: tagIDs,
is_created_by_me: isCreatedByMe,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
...(activeTab !== 'all' ? { mode: activeTab } : {}),
}
}), [activeTab, isCreatedByMe, searchKeywords, tagIDs])
const {
data,
@ -108,14 +107,27 @@ const List: FC<Props> = ({
hasNextPage,
error,
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
} = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
...appListQuery,
page: Number(pageParam),
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
enabled: !isCurrentWorkspaceDatasetOperator,
refetchInterval: systemFeatures.enable_collaboration_mode ? 10000 : false,
})
useEffect(() => {
if (controlRefreshList > 0) {
refetch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlRefreshList])
}, [controlRefreshList, refetch])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
@ -187,53 +199,23 @@ const List: FC<Props> = ({
}, [isCreatedByMe, setQuery])
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
const workflowOnlineUserAppIds = useMemo(() => {
const appIds = new Set<string>()
pages.forEach(({ data: apps }) => {
apps.forEach((app) => {
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
appIds.add(app.id)
})
apps.forEach((app) => {
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
appIds.add(app.id)
})
return Array.from(appIds)
}, [pages])
}, [apps])
const refreshWorkflowOnlineUsers = useCallback(async () => {
if (!systemFeatures.enable_collaboration_mode) {
setWorkflowOnlineUsersMap({})
return
}
if (!workflowOnlineUserAppIds.length) {
setWorkflowOnlineUsersMap({})
return
}
try {
const onlineUsersMap = await fetchWorkflowOnlineUsers({ appIds: workflowOnlineUserAppIds })
setWorkflowOnlineUsersMap(onlineUsersMap)
}
catch {
setWorkflowOnlineUsersMap({})
}
}, [systemFeatures.enable_collaboration_mode, workflowOnlineUserAppIds])
useEffect(() => {
void refreshWorkflowOnlineUsers()
}, [refreshWorkflowOnlineUsers])
useEffect(() => {
if (!systemFeatures.enable_collaboration_mode)
return
const timer = window.setInterval(() => {
void refetch()
void refreshWorkflowOnlineUsers()
}, 10000)
return () => window.clearInterval(timer)
}, [refetch, refreshWorkflowOnlineUsers, systemFeatures.enable_collaboration_mode])
const {
onlineUsersMap: workflowOnlineUsersMap,
} = useWorkflowOnlineUsers({
appIds: workflowOnlineUserAppIds,
enabled: systemFeatures.enable_collaboration_mode,
})
const hasAnyApp = (pages[0]?.total ?? 0) > 0
// Show skeleton during initial load or when refetching with no previous data
@ -288,24 +270,18 @@ const List: FC<Props> = ({
className={cn(!hasAnyApp && 'z-10')}
/>
)}
{(() => {
if (showSkeleton)
return <AppCardSkeleton count={6} />
if (hasAnyApp) {
return pages.flatMap(({ data: apps }) => apps).map(app => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
onRefresh={refetch}
/>
))
}
// No apps - show empty state
return <Empty />
})()}
{showSkeleton
? <AppCardSkeleton count={6} />
: hasAnyApp
? apps.map(app => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
onRefresh={refetch}
/>
))
: <Empty />}
{isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}

View File

@ -1,12 +1,14 @@
import { useInfiniteQuery } from '@tanstack/react-query'
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 { useAppContext } from '@/context/app-context'
import { useParams } from '@/next/navigation'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import AppNav from '../index'
const mockAppListInfiniteOptions = vi.hoisted(() => vi.fn((options: unknown) => options))
vi.mock('@/next/navigation', () => ({
useParams: vi.fn(),
}))
@ -25,10 +27,24 @@ vi.mock('@/app/components/app/store', () => ({
useStore: vi.fn(),
}))
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: vi.fn(),
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
list: {
infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options),
},
},
},
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useInfiniteQuery: vi.fn(),
}
})
vi.mock('@/app/components/app/create-app-dialog', () => ({
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) =>
show
@ -130,8 +146,12 @@ const mockAppData = [
const mockUseParams = vi.mocked(useParams)
const mockUseAppContext = vi.mocked(useAppContext)
const mockUseAppStore = vi.mocked(useAppStore)
const mockUseInfiniteAppList = vi.mocked(useInfiniteAppList)
const mockUseInfiniteQuery = vi.mocked(useInfiniteQuery)
let mockAppDetail: { id: string, name: string } | null = null
type AppListInfiniteOptions = {
input: (pageParam: number) => { query: { page: number, limit: number, name: string } }
getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined
}
const setupDefaultMocks = (options?: {
hasNextPage?: boolean
@ -146,13 +166,13 @@ const setupDefaultMocks = (options?: {
mockUseParams.mockReturnValue({ appId: 'app-1' } as ReturnType<typeof useParams>)
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceEditor: options?.isEditor ?? false } as ReturnType<typeof useAppContext>)
mockUseAppStore.mockImplementation((selector: unknown) => (selector as (state: { appDetail: { id: string, name: string } | null }) => unknown)({ appDetail: mockAppDetail }))
mockUseInfiniteAppList.mockReturnValue({
mockUseInfiniteQuery.mockReturnValue({
data: { pages: [{ data: options?.appData ?? mockAppData }] },
fetchNextPage,
hasNextPage: options?.hasNextPage ?? false,
isFetchingNextPage: false,
refetch,
} as ReturnType<typeof useInfiniteAppList>)
} as ReturnType<typeof useInfiniteQuery>)
return { refetch, fetchNextPage }
}
@ -164,6 +184,23 @@ describe('AppNav', () => {
setupDefaultMocks()
})
it('should configure paged app list query options', () => {
setupDefaultMocks()
render(<AppNav />)
const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions
expect(options.input(3)).toEqual({
query: {
page: 3,
limit: 30,
name: '',
},
})
expect(options.getNextPageParam({ has_more: true, page: 3 })).toBe(4)
expect(options.getNextPageParam({ has_more: false, page: 3 })).toBeUndefined()
})
it('should build editor links and update app name when app detail changes', async () => {
setupDefaultMocks({
isEditor: true,
@ -282,13 +319,13 @@ describe('AppNav', () => {
// Arrange
setupDefaultMocks()
mockUseParams.mockReturnValue({} as ReturnType<typeof useParams>)
mockUseInfiniteAppList.mockReturnValue({
mockUseInfiniteQuery.mockReturnValue({
data: undefined,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useInfiniteAppList>)
} as unknown as ReturnType<typeof useInfiniteQuery>)
// Act
render(<AppNav />)

View File

@ -1,19 +1,19 @@
'use client'
import type { NavItem } from '../nav/nav-selector'
import type { AppListQuery } from '@/contract/console/apps'
import {
RiRobot2Fill,
RiRobot2Line,
} from '@remixicon/react'
import { flatten } from 'es-toolkit/compat'
import { produce } from 'immer'
import { useCallback, useEffect, useState } from 'react'
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
import dynamic from '@/next/dynamic'
import { useParams } from '@/next/navigation'
import { useInfiniteAppList } from '@/service/use-apps'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import Nav from '../nav'
@ -21,6 +21,22 @@ const CreateAppTemplateDialog = dynamic(() => import('@/app/components/app/creat
const CreateAppModal = dynamic(() => import('@/app/components/app/create-app-modal'), { ssr: false })
const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), { ssr: false })
const appNavListQuery = {
page: 1,
limit: 30,
name: '',
} satisfies AppListQuery
const getAppLink = (isCurrentWorkspaceEditor: boolean, appId: string, appMode: AppModeEnum) => {
if (!isCurrentWorkspaceEditor)
return `/app/${appId}/overview`
if (appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT)
return `/app/${appId}/workflow`
return `/app/${appId}/configuration`
}
const AppNav = () => {
const { t } = useTranslation()
const { appId } = useParams()
@ -29,7 +45,6 @@ const AppNav = () => {
const [showNewAppDialog, setShowNewAppDialog] = useState(false)
const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [navItems, setNavItems] = useState<NavItem[]>([])
const {
data: appsData,
@ -37,11 +52,20 @@ const AppNav = () => {
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfiniteAppList({
page: 1,
limit: 30,
name: '',
}, { enabled: !!appId })
} = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
...appNavListQuery,
page: Number(pageParam),
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
enabled: !!appId,
})
const handleLoadMore = useCallback(() => {
if (hasNextPage)
@ -57,48 +81,20 @@ 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<NavItem[]>(() => {
const appItems = appsData?.pages.flatMap(appData => appData.data) ?? []
// 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])
return appItems.map(app => ({
id: app.id,
icon_type: app.icon_type,
icon: app.icon,
icon_background: app.icon_background,
icon_url: app.icon_url,
name: appDetail?.id === app.id ? appDetail.name : app.name,
mode: app.mode,
link: getAppLink(isCurrentWorkspaceEditor, app.id, app.mode),
}))
}, [appDetail?.id, appDetail?.name, appsData?.pages, isCurrentWorkspaceEditor])
return (
<>

View File

@ -15,6 +15,8 @@ import AppSelector from '../index'
// ==================== Mock Setup ====================
const mockAppListInfiniteOptions = vi.hoisted(() => vi.fn((options: unknown) => options))
// Mock IntersectionObserver globally using class syntax
let intersectionObserverCallback: IntersectionObserverCallback | null = null
const mockIntersectionObserver = {
@ -163,19 +165,36 @@ const getAppDetailData = (appId: string) => {
}
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: mockAppListData,
isLoading: mockIsLoading,
isFetchingNextPage: mockIsFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockHasNextPage,
}),
useAppDetail: (appId: string) => ({
data: getAppDetailData(appId),
isFetching: mockAppDetailLoading,
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
list: {
infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options),
},
},
},
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useInfiniteQuery: () => ({
data: mockAppListData,
isLoading: mockIsLoading,
isFetchingNextPage: mockIsFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockHasNextPage,
}),
}
})
// Allow configurable mock data for useAppWorkflow
let mockWorkflowData: Record<string, unknown> | undefined | null
let mockWorkflowLoading = false
@ -323,6 +342,11 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
)
}
type AppSelectorInfiniteOptions = {
input: (pageParam: number) => { query: Record<string, unknown> }
getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined
}
// Mock data factories
const createMockApp = (overrides: Record<string, unknown> = {}): App => ({
id: 'app-1',
@ -1539,6 +1563,22 @@ describe('AppSelector', () => {
expect(screen.getByText('app.appSelector.placeholder'))!.toBeInTheDocument()
})
it('should configure paged app list query options', () => {
renderWithQueryClient(<AppSelector {...defaultProps} />)
const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppSelectorInfiniteOptions
expect(options.input(4)).toEqual({
query: {
page: 4,
limit: 20,
name: '',
},
})
expect(options.getNextPageParam({ has_more: true, page: 4 })).toBe(5)
expect(options.getNextPageParam({ has_more: false, page: 4 })).toBeUndefined()
})
it('should show selected app info when value is provided', () => {
renderWithQueryClient(
<AppSelector

View File

@ -4,19 +4,22 @@ import type {
Placement,
} from '@floating-ui/react'
import type { FC } from 'react'
import type { AppListQuery } from '@/contract/console/apps'
import type { App } from '@/types/app'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel'
import AppPicker from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker'
import AppTrigger from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger'
import { useAppDetail, useInfiniteAppList } from '@/service/use-apps'
import { consoleQuery } from '@/service/client'
import { useAppDetail } from '@/service/use-apps'
const PAGE_SIZE = 20
@ -50,16 +53,30 @@ const AppSelector: FC<Props> = ({
const [isShow, setIsShow] = useState(false)
const [searchText, setSearchText] = useState('')
const appListQuery = useMemo<AppListQuery>(() => ({
page: 1,
limit: PAGE_SIZE,
name: searchText,
}), [searchText])
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useInfiniteAppList({
page: 1,
limit: PAGE_SIZE,
name: searchText,
} = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
...appListQuery,
page: Number(pageParam),
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
})
const displayedApps = useMemo(() => {

View File

@ -1,4 +1,4 @@
import type { WorkflowCommentList } from '@/service/workflow-comment'
import type { WorkflowCommentList } from '@/contract/console/workflow-comment'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CommentIcon } from './comment-icon'

View File

@ -1,7 +1,7 @@
'use client'
import type { FC, PointerEvent as ReactPointerEvent } from 'react'
import type { WorkflowCommentList } from '@/service/workflow-comment'
import type { WorkflowCommentList } from '@/contract/console/workflow-comment'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { useReactFlow, useViewport } from 'reactflow'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'

View File

@ -1,9 +1,9 @@
import type { WorkflowCommentList } from '@/service/workflow-comment'
import type { WorkflowCommentList } from '@/contract/console/workflow-comment'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CommentPreview from './comment-preview'
type UserProfile = WorkflowCommentList['created_by_account']
type UserProfile = NonNullable<WorkflowCommentList['created_by_account']>
const mockSetHovering = vi.fn()
let capturedUsers: UserProfile[] = []

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import type { WorkflowCommentList } from '@/service/workflow-comment'
import type { WorkflowCommentList } from '@/contract/console/workflow-comment'
import { memo, useEffect, useMemo } from 'react'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
@ -15,6 +15,7 @@ type CommentPreviewProps = {
const CommentPreview: FC<CommentPreviewProps> = ({ comment, onClick }) => {
const { formatTimeFromNow } = useFormatTimeFromNow()
const setCommentPreviewHovering = useStore(s => s.setCommentPreviewHovering)
const authorName = comment.created_by_account?.name ?? ''
const participants = useMemo(() => {
const list = comment.participants ?? []
const author = comment.created_by_account
@ -44,7 +45,7 @@ const CommentPreview: FC<CommentPreviewProps> = ({ comment, onClick }) => {
<div className="mb-2 flex items-start">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate system-sm-medium text-text-primary">{comment.created_by_account.name}</div>
<div className="truncate system-sm-medium text-text-primary">{authorName}</div>
<div className="shrink-0 system-2xs-regular text-text-tertiary">
{formatTimeFromNow(comment.updated_at * 1000)}
</div>

View File

@ -1,5 +1,5 @@
import type { UserProfile } from '@/service/workflow-comment'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { UserProfile } from '@/contract/console/workflow-comment'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { MentionInput } from './mention-input'
@ -30,8 +30,12 @@ vi.mock('@/next/navigation', () => ({
useParams: () => ({ appId: 'app-1' }),
}))
vi.mock('@/service/workflow-comment', () => ({
fetchMentionableUsers: (...args: unknown[]) => mockFetchMentionableUsers(...args),
vi.mock('@/service/client', () => ({
consoleClient: {
workflowComments: {
mentionUsers: (...args: unknown[]) => mockFetchMentionableUsers(...args),
},
},
}))
vi.mock('../store', () => ({
@ -80,7 +84,7 @@ describe('MentionInput', () => {
vi.clearAllMocks()
mentionStoreState.mentionableUsersCache = {}
mentionStoreState.mentionableUsersLoading = {}
mockFetchMentionableUsers.mockResolvedValue(mentionUsers)
mockFetchMentionableUsers.mockResolvedValue({ users: mentionUsers })
})
it('loads mentionable users when cache is empty', async () => {
@ -93,7 +97,9 @@ describe('MentionInput', () => {
)
await waitFor(() => {
expect(mockFetchMentionableUsers).toHaveBeenCalledWith('app-1')
expect(mockFetchMentionableUsers).toHaveBeenCalledWith({
params: { appId: 'app-1' },
})
})
expect(mockSetMentionableUsersLoading).toHaveBeenCalledWith('app-1', true)
@ -148,4 +154,35 @@ describe('MentionInput', () => {
expect(onSubmit).toHaveBeenCalledWith('updated reply', [])
})
})
it('focuses the textarea at the end when autoFocus is enabled', () => {
vi.useFakeTimers()
try {
mentionStoreState.mentionableUsersCache['app-1'] = mentionUsers
const { unmount } = render(
<MentionInput
value="draft"
onChange={vi.fn()}
onSubmit={vi.fn()}
autoFocus
/>,
)
const textarea = screen.getByPlaceholderText('workflow.comments.placeholder.add') as HTMLTextAreaElement
act(() => {
vi.runOnlyPendingTimers()
})
expect(document.activeElement).toBe(textarea)
expect(textarea.selectionStart).toBe(5)
expect(textarea.selectionEnd).toBe(5)
unmount()
}
finally {
vi.useRealTimers()
}
})
})

View File

@ -1,7 +1,7 @@
'use client'
import type { ReactNode } from 'react'
import type { UserProfile } from '@/service/workflow-comment'
import type { UserProfile } from '@/contract/console/workflow-comment'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@ -22,7 +22,7 @@ import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
import EnterKey from '@/app/components/base/icons/src/public/common/EnterKey'
import { useParams } from '@/next/navigation'
import { fetchMentionableUsers } from '@/service/workflow-comment'
import { consoleClient } from '@/service/client'
import { useStore, useWorkflowStore } from '../store'
type MentionInputProps = {
@ -38,6 +38,8 @@ type MentionInputProps = {
autoFocus?: boolean
}
const EMPTY_USERS: UserProfile[] = []
const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(({
value,
onChange,
@ -66,7 +68,7 @@ const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(({
const mentionUsersFromStore = useStore(state => (
appId ? state.mentionableUsersCache[appId] : undefined
))
const mentionUsers = mentionUsersFromStore ?? []
const mentionUsers = useMemo(() => mentionUsersFromStore ?? EMPTY_USERS, [mentionUsersFromStore])
const [showMentionDropdown, setShowMentionDropdown] = useState(false)
const [mentionQuery, setMentionQuery] = useState('')
@ -163,8 +165,10 @@ const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(({
state.setMentionableUsersLoading(appId, true)
try {
const users = await fetchMentionableUsers(appId)
workflowStore.getState().setMentionableUsersCache(appId, users)
const response = await consoleClient.workflowComments.mentionUsers({
params: { appId },
})
workflowStore.getState().setMentionableUsersCache(appId, response.users)
}
catch (error) {
console.error('Failed to load mentionable users:', error)
@ -495,14 +499,17 @@ const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(({
}, [value, resetMentionState])
useEffect(() => {
if (autoFocus && textareaRef.current) {
const textarea = textareaRef.current
setTimeout(() => {
textarea.focus()
const length = textarea.value.length
textarea.setSelectionRange(length, length)
}, 0)
}
if (!autoFocus || !textareaRef.current)
return
const textarea = textareaRef.current
const timeout = window.setTimeout(() => {
textarea.focus()
const length = textarea.value.length
textarea.setSelectionRange(length, length)
}, 0)
return () => window.clearTimeout(timeout)
}, [autoFocus])
return (

View File

@ -1,4 +1,4 @@
import type { WorkflowCommentDetail } from '@/service/workflow-comment'
import type { WorkflowCommentDetail } from '@/contract/console/workflow-comment'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { CommentThread } from './thread'

View File

@ -1,7 +1,7 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment'
import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/contract/console/workflow-comment'
import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {

View File

@ -1,4 +1,4 @@
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/contract/console/workflow-comment'
import { act, waitFor } from '@testing-library/react'
import { createTestQueryClient, seedSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
@ -52,16 +52,30 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/service/workflow-comment', () => ({
createWorkflowComment: (...args: unknown[]) => mockCreateWorkflowComment(...args),
createWorkflowCommentReply: (...args: unknown[]) => mockCreateWorkflowCommentReply(...args),
deleteWorkflowComment: (...args: unknown[]) => mockDeleteWorkflowComment(...args),
deleteWorkflowCommentReply: (...args: unknown[]) => mockDeleteWorkflowCommentReply(...args),
fetchWorkflowComment: (...args: unknown[]) => mockFetchWorkflowComment(...args),
fetchWorkflowComments: (...args: unknown[]) => mockFetchWorkflowComments(...args),
resolveWorkflowComment: (...args: unknown[]) => mockResolveWorkflowComment(...args),
updateWorkflowComment: (...args: unknown[]) => mockUpdateWorkflowComment(...args),
updateWorkflowCommentReply: (...args: unknown[]) => mockUpdateWorkflowCommentReply(...args),
vi.mock('@/service/client', () => ({
consoleClient: {
systemFeatures: () => ({
enable_collaboration_mode: globalFeatureState.enableCollaboration,
}),
workflowComments: {
create: (...args: unknown[]) => mockCreateWorkflowComment(...args),
delete: (...args: unknown[]) => mockDeleteWorkflowComment(...args),
detail: (...args: unknown[]) => mockFetchWorkflowComment(...args),
list: (...args: unknown[]) => mockFetchWorkflowComments(...args),
resolve: (...args: unknown[]) => mockResolveWorkflowComment(...args),
update: (...args: unknown[]) => mockUpdateWorkflowComment(...args),
replies: {
create: (...args: unknown[]) => mockCreateWorkflowCommentReply(...args),
delete: (...args: unknown[]) => mockDeleteWorkflowCommentReply(...args),
update: (...args: unknown[]) => mockUpdateWorkflowCommentReply(...args),
},
},
},
consoleQuery: {
systemFeatures: {
queryKey: () => ['console', 'systemFeatures'],
},
},
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
@ -127,25 +141,27 @@ describe('useWorkflowComment', () => {
commentsUpdateState.handler = undefined
globalFeatureState.enableCollaboration = true
mockFetchWorkflowComments.mockResolvedValue([])
mockFetchWorkflowComments.mockResolvedValue({ data: [] })
mockFetchWorkflowComment.mockResolvedValue(baseCommentDetail())
mockCreateWorkflowComment.mockResolvedValue({
id: 'comment-2',
created_at: '1700000000',
created_at: 1700000000,
})
mockUpdateWorkflowComment.mockResolvedValue({})
})
it('loads comment list on mount when collaboration is enabled', async () => {
const comment = baseComment()
mockFetchWorkflowComments.mockResolvedValue([comment])
mockFetchWorkflowComments.mockResolvedValue({ data: [comment] })
const { store } = renderWorkflowHook(() => useWorkflowComment(), {
queryClient: createSeededQueryClient(),
})
await waitFor(() => {
expect(mockFetchWorkflowComments).toHaveBeenCalledWith('app-1')
expect(mockFetchWorkflowComments).toHaveBeenCalledWith({
params: { appId: 'app-1' },
})
})
expect(store.getState().comments).toEqual([comment])
@ -186,11 +202,14 @@ describe('useWorkflowComment', () => {
await result.current.handleCommentSubmit('new message', ['user-2'])
})
expect(mockCreateWorkflowComment).toHaveBeenCalledWith('app-1', {
position_x: 10,
position_y: 20,
content: 'new message',
mentioned_user_ids: ['user-2'],
expect(mockCreateWorkflowComment).toHaveBeenCalledWith({
params: { appId: 'app-1' },
body: {
position_x: 10,
position_y: 20,
content: 'new message',
mentioned_user_ids: ['user-2'],
},
})
expect(mockEmitCommentsUpdate).toHaveBeenCalledWith('app-1')
@ -214,6 +233,79 @@ describe('useWorkflowComment', () => {
expect(store.getState().isCommentQuickAdd).toBe(false)
})
it('normalizes numeric string timestamps when creating a comment', async () => {
mockCreateWorkflowComment.mockResolvedValue({
id: 'comment-string-time',
created_at: '1700001234',
})
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
queryClient: createSeededQueryClient(),
initialStoreState: {
comments: [],
pendingComment: { pageX: 100, pageY: 200, elementX: 10, elementY: 20 },
isCommentQuickAdd: true,
},
})
await act(async () => {
await result.current.handleCommentSubmit('new message')
})
expect(store.getState().comments[0]).toMatchObject({
id: 'comment-string-time',
created_at: 1700001234,
updated_at: 1700001234,
})
})
it('normalizes ISO timestamps and keeps unresolved mentions as null', async () => {
const createdAt = '2024-01-02T03:04:05.000Z'
mockCreateWorkflowComment.mockResolvedValue({
id: 'comment-date-time',
created_at: createdAt,
})
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
queryClient: createSeededQueryClient(),
initialStoreState: {
comments: [],
pendingComment: { pageX: 100, pageY: 200, elementX: 10, elementY: 20 },
isCommentQuickAdd: true,
mentionableUsersCache: {
'app-1': [{
id: 'user-2',
name: 'Bob',
email: 'bob@example.com',
avatar_url: 'bob.png',
}],
},
},
})
await act(async () => {
await result.current.handleCommentSubmit('new message', ['missing-user'])
})
const expectedCreatedAt = Math.floor(Date.parse(createdAt) / 1000)
expect(store.getState().comments[0]).toMatchObject({
id: 'comment-date-time',
created_at: expectedCreatedAt,
updated_at: expectedCreatedAt,
participants: [{
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
avatar_url: 'alice.png',
}],
})
expect(store.getState().commentDetailCache['comment-date-time']?.mentions).toEqual([{
mentioned_user_id: 'missing-user',
mentioned_user_account: null,
reply_id: null,
}])
})
it('rolls back optimistic position update when API update fails', async () => {
const comment = baseComment()
const commentDetail = baseCommentDetail()
@ -235,10 +327,13 @@ describe('useWorkflowComment', () => {
await result.current.handleCommentPositionUpdate(comment.id, { x: 300, y: 400 })
})
expect(mockUpdateWorkflowComment).toHaveBeenCalledWith('app-1', comment.id, {
content: 'hello',
position_x: 300,
position_y: 400,
expect(mockUpdateWorkflowComment).toHaveBeenCalledWith({
params: { appId: 'app-1', commentId: comment.id },
body: {
content: 'hello',
position_x: 300,
position_y: 400,
},
})
expect(mockEmitCommentsUpdate).not.toHaveBeenCalled()
expect(store.getState().comments[0]).toMatchObject({
@ -257,8 +352,8 @@ describe('useWorkflowComment', () => {
...baseCommentDetail(),
content: 'updated by another user',
}
mockFetchWorkflowComments.mockResolvedValue([comment])
mockFetchWorkflowComment.mockResolvedValue({ data: detail })
mockFetchWorkflowComments.mockResolvedValue({ data: [comment] })
mockFetchWorkflowComment.mockResolvedValue(detail)
const { unmount } = renderWorkflowHook(() => useWorkflowComment(), {
queryClient: createSeededQueryClient(),
@ -276,7 +371,9 @@ describe('useWorkflowComment', () => {
})
await waitFor(() => {
expect(mockFetchWorkflowComment).toHaveBeenCalledWith('app-1', comment.id)
expect(mockFetchWorkflowComment).toHaveBeenCalledWith({
params: { appId: 'app-1', commentId: comment.id },
})
})
expect(mockFetchWorkflowComments).toHaveBeenCalledTimes(2)
@ -362,7 +459,7 @@ describe('useWorkflowComment', () => {
position_x: 33,
position_y: 55,
}
mockFetchWorkflowComments.mockResolvedValue([commentB])
mockFetchWorkflowComments.mockResolvedValue({ data: [commentB] })
mockFetchWorkflowComment.mockResolvedValue({
...baseCommentDetail(),
id: commentB.id,
@ -383,7 +480,9 @@ describe('useWorkflowComment', () => {
await result.current.handleCommentResolve(commentA.id)
})
expect(mockResolveWorkflowComment).toHaveBeenCalledWith('app-1', commentA.id)
expect(mockResolveWorkflowComment).toHaveBeenCalledWith({
params: { appId: 'app-1', commentId: commentA.id },
})
await act(async () => {
await result.current.handleCommentReply(commentA.id, ' new reply ', ['user-2'])
@ -391,24 +490,154 @@ describe('useWorkflowComment', () => {
await result.current.handleCommentReplyDelete(commentA.id, 'reply-1')
})
expect(mockCreateWorkflowCommentReply).toHaveBeenCalledWith('app-1', commentA.id, {
content: 'new reply',
mentioned_user_ids: ['user-2'],
expect(mockCreateWorkflowCommentReply).toHaveBeenCalledWith({
params: { appId: 'app-1', commentId: commentA.id },
body: {
content: 'new reply',
mentioned_user_ids: ['user-2'],
},
})
expect(mockUpdateWorkflowCommentReply).toHaveBeenCalledWith('app-1', commentA.id, 'reply-1', {
content: 'edited reply',
mentioned_user_ids: ['user-2'],
expect(mockUpdateWorkflowCommentReply).toHaveBeenCalledWith({
params: { appId: 'app-1', commentId: commentA.id, replyId: 'reply-1' },
body: {
content: 'edited reply',
mentioned_user_ids: ['user-2'],
},
})
expect(mockDeleteWorkflowCommentReply).toHaveBeenCalledWith({
params: { appId: 'app-1', commentId: commentA.id, replyId: 'reply-1' },
})
expect(mockDeleteWorkflowCommentReply).toHaveBeenCalledWith('app-1', commentA.id, 'reply-1')
await act(async () => {
await result.current.handleCommentDelete(commentA.id)
})
expect(mockDeleteWorkflowComment).toHaveBeenCalledWith('app-1', commentA.id)
expect(mockDeleteWorkflowComment).toHaveBeenCalledWith({
params: { appId: 'app-1', commentId: commentA.id },
})
await waitFor(() => {
expect(store.getState().activeCommentId).toBe(commentB.id)
})
expect(mockEmitCommentsUpdate).toHaveBeenCalled()
})
it('does not update a reply when the content is blank', async () => {
const { result } = renderWorkflowHook(() => useWorkflowComment(), {
queryClient: createSeededQueryClient(),
})
await act(async () => {
await result.current.handleCommentReplyUpdate('comment-1', 'reply-1', ' ')
})
expect(mockUpdateWorkflowCommentReply).not.toHaveBeenCalled()
})
it('resets reply submit loading when creation fails', async () => {
mockCreateWorkflowCommentReply.mockRejectedValueOnce(new Error('create reply failed'))
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
queryClient: createSeededQueryClient(),
initialStoreState: {
replySubmitting: false,
},
})
await act(async () => {
await result.current.handleCommentReply('comment-1', 'new reply')
})
expect(mockCreateWorkflowCommentReply).toHaveBeenCalledWith({
params: { appId: 'app-1', commentId: 'comment-1' },
body: {
content: 'new reply',
mentioned_user_ids: [],
},
})
expect(store.getState().replySubmitting).toBe(false)
})
it('resets reply update loading when update fails', async () => {
mockUpdateWorkflowCommentReply.mockRejectedValueOnce(new Error('update failed'))
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
queryClient: createSeededQueryClient(),
initialStoreState: {
replyUpdating: false,
},
})
await act(async () => {
await result.current.handleCommentReplyUpdate('comment-1', 'reply-1', 'updated reply')
})
expect(mockUpdateWorkflowCommentReply).toHaveBeenCalledWith({
params: { appId: 'app-1', commentId: 'comment-1', replyId: 'reply-1' },
body: {
content: 'updated reply',
mentioned_user_ids: [],
},
})
expect(store.getState().replyUpdating).toBe(false)
})
it('resets reply delete loading when deletion fails', async () => {
mockDeleteWorkflowCommentReply.mockRejectedValueOnce(new Error('delete failed'))
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
queryClient: createSeededQueryClient(),
initialStoreState: {
activeCommentDetailLoading: false,
},
})
await act(async () => {
await result.current.handleCommentReplyDelete('comment-1', 'reply-1')
})
expect(mockDeleteWorkflowCommentReply).toHaveBeenCalledWith({
params: { appId: 'app-1', commentId: 'comment-1', replyId: 'reply-1' },
})
expect(store.getState().activeCommentDetailLoading).toBe(false)
})
it('ignores navigation when no active comment or active comment is absent from the list', () => {
const { result } = renderWorkflowHook(() => useWorkflowComment(), {
queryClient: createSeededQueryClient(),
initialStoreState: {
comments: [baseComment()],
},
})
act(() => {
result.current.handleCommentNavigate('next')
})
expect(mockSetCenter).not.toHaveBeenCalled()
act(() => {
result.current.handleCommentIconClick({ ...baseComment(), id: 'missing-comment' })
})
mockSetCenter.mockClear()
act(() => {
result.current.handleCommentNavigate('next')
})
expect(mockSetCenter).not.toHaveBeenCalled()
})
it('clears a pending comment when comment mode is left outside quick add', () => {
const { store } = renderWorkflowHook(() => useWorkflowComment(), {
queryClient: createSeededQueryClient(),
initialStoreState: {
controlMode: ControlMode.Pointer,
isCommentQuickAdd: false,
pendingComment: { pageX: 1, pageY: 2, elementX: 3, elementY: 4 },
},
})
expect(store.getState().pendingComment).toBeNull()
})
})

View File

@ -1,24 +1,34 @@
import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/contract/console/workflow-comment'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useReactFlow } from 'reactflow'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { useAppContext } from '@/context/app-context'
import { useParams } from '@/next/navigation'
import { consoleClient } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, deleteWorkflowCommentReply, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment, updateWorkflowComment, updateWorkflowCommentReply } from '@/service/workflow-comment'
import { useStore } from '../store'
import { ControlMode } from '../types'
const EMPTY_USERS: UserProfile[] = []
type CommentDetailResponse = WorkflowCommentDetail | { data: WorkflowCommentDetail }
const getCommentDetail = (response: CommentDetailResponse): WorkflowCommentDetail => {
if ('data' in response)
return response.data
return response
const normalizeTimestamp = (value: number | string): number => {
if (typeof value === 'number')
return value
const parsed = Number(value)
if (!Number.isNaN(parsed))
return parsed
return Math.floor(Date.parse(value) / 1000)
}
const toCommentDetailPreview = (comment: WorkflowCommentList): WorkflowCommentDetail => ({
...comment,
replies: [],
mentions: [],
})
export const useWorkflowComment = () => {
const params = useParams()
const appId = params.appId as string
@ -50,6 +60,10 @@ export const useWorkflowComment = () => {
const mentionableUsers = useStore(state => (
appId ? state.mentionableUsersCache[appId] ?? EMPTY_USERS : EMPTY_USERS
))
const mentionableUserById = useMemo(
() => new Map(mentionableUsers.map(user => [user.id, user])),
[mentionableUsers],
)
const { userProfile } = useAppContext()
const { data: isCollaborationEnabled } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
@ -70,8 +84,9 @@ export const useWorkflowComment = () => {
if (!appId)
return
const detailResponse = await fetchWorkflowComment(appId, commentId) as CommentDetailResponse
const detail = getCommentDetail(detailResponse)
const detail = await consoleClient.workflowComments.detail({
params: { appId, commentId },
})
commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
@ -87,8 +102,10 @@ export const useWorkflowComment = () => {
setCommentsLoading(true)
try {
const commentsData = await fetchWorkflowComments(appId)
setComments(commentsData)
const response = await consoleClient.workflowComments.list({
params: { appId },
})
setComments(response.data)
}
catch (error) {
console.error('Failed to fetch comments:', error)
@ -133,17 +150,17 @@ export const useWorkflowComment = () => {
y: pendingComment.pageY,
})
const newComment = await createWorkflowComment(appId, {
position_x: flowPosition.x,
position_y: flowPosition.y,
content,
mentioned_user_ids: mentionedUserIds,
const newComment = await consoleClient.workflowComments.create({
params: { appId },
body: {
position_x: flowPosition.x,
position_y: flowPosition.y,
content,
mentioned_user_ids: mentionedUserIds,
},
})
const createdAt = Number(newComment.created_at)
const createdAtSeconds = Number.isNaN(createdAt)
? Math.floor(Date.parse(newComment.created_at) / 1000)
: createdAt
const createdAtSeconds = normalizeTimestamp(newComment.created_at)
const createdByAccount = {
id: userProfile?.id ?? '',
name: userProfile?.name ?? '',
@ -151,7 +168,7 @@ export const useWorkflowComment = () => {
avatar_url: userProfile?.avatar_url || userProfile?.avatar || undefined,
}
const mentionedUsers = mentionedUserIds
.map(mentionedId => mentionableUsers.find(user => user.id === mentionedId))
.map(mentionedId => mentionableUserById.get(mentionedId))
.filter((user): user is NonNullable<typeof user> => Boolean(user))
const uniqueParticipantsMap = new Map<string, typeof createdByAccount>()
if (createdByAccount.id)
@ -196,7 +213,7 @@ export const useWorkflowComment = () => {
replies: [],
mentions: mentionedUserIds.map(mentionedId => ({
mentioned_user_id: mentionedId,
mentioned_user_account: mentionableUsers.find(user => user.id === mentionedId) ?? null,
mentioned_user_account: mentionableUserById.get(mentionedId) ?? null,
reply_id: null,
})),
}
@ -218,7 +235,7 @@ export const useWorkflowComment = () => {
setPendingComment(null)
setCommentQuickAdd(false)
}
}, [appId, pendingComment, setPendingComment, setCommentQuickAdd, reactflow, comments, setComments, userProfile, setCommentDetailCache, mentionableUsers])
}, [appId, pendingComment, setPendingComment, setCommentQuickAdd, reactflow, comments, setComments, userProfile, setCommentDetailCache, mentionableUserById])
const handleCommentCancel = useCallback(() => {
setPendingComment(null)
@ -241,8 +258,8 @@ export const useWorkflowComment = () => {
activeCommentIdRef.current = comment.id
setActiveCommentId(comment.id)
const cachedDetail = commentDetailCacheRef.current[comment.id]!
setActiveComment(cachedDetail || comment)
const cachedDetail = commentDetailCacheRef.current[comment.id]
setActiveComment(cachedDetail ?? toCommentDetailPreview(comment))
const hasSelectedNode = reactflow.getNodes().some(node => node.data?.selected)
const commentPanelWidth = controlMode === ControlMode.Comment ? 420 : 0
@ -267,8 +284,9 @@ export const useWorkflowComment = () => {
setActiveCommentLoading(!cachedDetail)
try {
const detailResponse = await fetchWorkflowComment(appId, comment.id) as CommentDetailResponse
const detail = getCommentDetail(detailResponse)
const detail = await consoleClient.workflowComments.detail({
params: { appId, commentId: comment.id },
})
commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
@ -304,7 +322,9 @@ export const useWorkflowComment = () => {
setActiveCommentLoading(true)
try {
await resolveWorkflowComment(appId, commentId)
await consoleClient.workflowComments.resolve({
params: { appId, commentId },
})
collaborationManager.emitCommentsUpdate(appId)
@ -325,7 +345,9 @@ export const useWorkflowComment = () => {
setActiveCommentLoading(true)
try {
await deleteWorkflowComment(appId, commentId)
await consoleClient.workflowComments.delete({
params: { appId, commentId },
})
collaborationManager.emitCommentsUpdate(appId)
@ -399,10 +421,13 @@ export const useWorkflowComment = () => {
}
try {
await updateWorkflowComment(appId, commentId, {
content: targetComment.content,
position_x: nextPosition.position_x,
position_y: nextPosition.position_y,
await consoleClient.workflowComments.update({
params: { appId, commentId },
body: {
content: targetComment.content,
position_x: nextPosition.position_x,
position_y: nextPosition.position_y,
},
})
collaborationManager.emitCommentsUpdate(appId)
}
@ -443,11 +468,14 @@ export const useWorkflowComment = () => {
return
try {
await updateWorkflowComment(appId, commentId, {
content: trimmed,
position_x: positionX,
position_y: positionY,
mentioned_user_ids: mentionedUserIds,
await consoleClient.workflowComments.update({
params: { appId, commentId },
body: {
content: trimmed,
position_x: positionX,
position_y: positionY,
mentioned_user_ids: mentionedUserIds,
},
})
collaborationManager.emitCommentsUpdate(appId)
@ -469,7 +497,10 @@ export const useWorkflowComment = () => {
setReplySubmitting(true)
try {
await createWorkflowCommentReply(appId, commentId, { content: trimmed, mentioned_user_ids: mentionedUserIds })
await consoleClient.workflowComments.replies.create({
params: { appId, commentId },
body: { content: trimmed, mentioned_user_ids: mentionedUserIds },
})
collaborationManager.emitCommentsUpdate(appId)
@ -493,7 +524,10 @@ export const useWorkflowComment = () => {
setReplyUpdating(true)
try {
await updateWorkflowCommentReply(appId, commentId, replyId, { content: trimmed, mentioned_user_ids: mentionedUserIds })
await consoleClient.workflowComments.replies.update({
params: { appId, commentId, replyId },
body: { content: trimmed, mentioned_user_ids: mentionedUserIds },
})
collaborationManager.emitCommentsUpdate(appId)
@ -514,7 +548,9 @@ export const useWorkflowComment = () => {
setActiveCommentLoading(true)
try {
await deleteWorkflowCommentReply(appId, commentId, replyId)
await consoleClient.workflowComments.replies.delete({
params: { appId, commentId, replyId },
})
collaborationManager.emitCommentsUpdate(appId)

View File

@ -116,23 +116,30 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
}))
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: {
pages: [{
data: mockApps,
}],
},
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
}),
useAppDetail: (appId: string) => ({
data: mockApps.find(app => app.id === appId),
isFetching: false,
}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useInfiniteQuery: () => ({
data: {
pages: [{
data: mockApps,
}],
},
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
}),
}
})
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({
data: undefined,

View File

@ -1,14 +1,12 @@
import type { WorkflowCommentList } from '@/service/workflow-comment'
import type { WorkflowCommentList } from '@/contract/console/workflow-comment'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import CommentsPanel from '../index'
const mockHandleCommentIconClick = vi.hoisted(() => vi.fn())
const mockLoadComments = vi.hoisted(() => vi.fn())
const mockHandleCommentResolve = vi.hoisted(() => vi.fn())
const mockSetActiveCommentId = vi.hoisted(() => vi.fn())
const mockSetControlMode = vi.hoisted(() => vi.fn())
const mockSetShowResolvedComments = vi.hoisted(() => vi.fn())
const mockResolveWorkflowComment = vi.hoisted(() => vi.fn())
const mockEmitCommentsUpdate = vi.hoisted(() => vi.fn())
const commentFixtures: WorkflowCommentList[] = [
{
@ -90,21 +88,11 @@ vi.mock('@/app/components/workflow/hooks/use-workflow-comment', () => ({
useWorkflowComment: () => ({
comments: commentFixtures,
loading: false,
loadComments: (...args: unknown[]) => mockLoadComments(...args),
handleCommentIconClick: (...args: unknown[]) => mockHandleCommentIconClick(...args),
handleCommentResolve: (...args: unknown[]) => mockHandleCommentResolve(...args),
}),
}))
vi.mock('@/service/workflow-comment', () => ({
resolveWorkflowComment: (...args: unknown[]) => mockResolveWorkflowComment(...args),
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: {
emitCommentsUpdate: (...args: unknown[]) => mockEmitCommentsUpdate(...args),
},
}))
vi.mock('@/app/components/base/user-avatar-list', () => ({
UserAvatarList: () => <div data-testid="user-avatar-list" />,
}))
@ -122,8 +110,7 @@ describe('CommentsPanel', () => {
vi.clearAllMocks()
storeState.activeCommentId = null
storeState.showResolvedComments = true
mockResolveWorkflowComment.mockResolvedValue({})
mockLoadComments.mockResolvedValue(undefined)
mockHandleCommentResolve.mockResolvedValue(undefined)
})
it('filters comments and selects a thread', () => {
@ -149,9 +136,7 @@ describe('CommentsPanel', () => {
fireEvent.click(resolveIcons[0]!)
await waitFor(() => {
expect(mockResolveWorkflowComment).toHaveBeenCalledWith('app-1', 'c-1')
expect(mockEmitCommentsUpdate).toHaveBeenCalledWith('app-1')
expect(mockLoadComments).toHaveBeenCalled()
expect(mockHandleCommentResolve).toHaveBeenCalledWith('c-1')
expect(mockSetActiveCommentId).toHaveBeenCalledWith('c-1')
})
})

View File

@ -1,4 +1,4 @@
import type { WorkflowCommentList } from '@/service/workflow-comment'
import type { WorkflowCommentList } from '@/contract/console/workflow-comment'
import { cn } from '@langgenius/dify-ui/cn'
import { Switch } from '@langgenius/dify-ui/switch'
import { RiCheckboxCircleFill, RiCheckboxCircleLine, RiCheckLine, RiCloseLine, RiFilter3Line } from '@remixicon/react'
@ -6,14 +6,11 @@ import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { useWorkflowComment } from '@/app/components/workflow/hooks/use-workflow-comment'
import { useStore } from '@/app/components/workflow/store'
import { ControlMode } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useParams } from '@/next/navigation'
import { resolveWorkflowComment } from '@/service/workflow-comment'
const CommentsPanel = () => {
const { t } = useTranslation()
@ -22,9 +19,7 @@ const CommentsPanel = () => {
const setControlMode = useStore(s => s.setControlMode)
const showResolvedComments = useStore(s => s.showResolvedComments)
const setShowResolvedComments = useStore(s => s.setShowResolvedComments)
const { comments, loading, loadComments, handleCommentIconClick } = useWorkflowComment()
const params = useParams()
const appId = params.appId as string
const { comments, loading, handleCommentIconClick, handleCommentResolve } = useWorkflowComment()
const { formatTimeFromNow } = useFormatTimeFromNow()
const [showOnlyMine, setShowOnlyMine] = useState(false)
@ -48,20 +43,14 @@ const CommentsPanel = () => {
const handleResolve = useCallback(async (comment: WorkflowCommentList) => {
if (comment.resolved)
return
if (!appId)
return
try {
await resolveWorkflowComment(appId, comment.id)
collaborationManager.emitCommentsUpdate(appId)
await loadComments()
await handleCommentResolve(comment.id)
setActiveCommentId(comment.id)
}
catch (e) {
console.error('Resolve comment failed', e)
}
}, [appId, loadComments, setActiveCommentId])
}, [handleCommentResolve, setActiveCommentId])
const hasActiveFilter = showOnlyMine || !showResolvedComments
@ -172,7 +161,7 @@ const CommentsPanel = () => {
{/* Header row: creator + time */}
<div className="flex items-start">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate system-sm-medium text-text-primary">{c.created_by_account.name}</div>
<div className="truncate system-sm-medium text-text-primary">{c.created_by_account?.name ?? ''}</div>
<div className="shrink-0 system-2xs-regular text-text-tertiary">
{formatTimeFromNow(c.updated_at * 1000)}
</div>

View File

@ -1,5 +1,5 @@
import type { StateCreator } from 'zustand'
import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/contract/console/workflow-comment'
export type CommentSliceShape = {
comments: WorkflowCommentList[]

View File

@ -1,7 +1,27 @@
import type { WorkflowOnlineUsersResponse } from '@/models/app'
import type { AppListResponse, WorkflowOnlineUsersResponse } from '@/models/app'
import type { AppModeEnum } from '@/types/app'
import { type } from '@orpc/contract'
import { base } from '../base'
export type AppListQuery = {
page?: number
limit?: number
name?: string
mode?: AppModeEnum
tag_ids?: string[]
is_created_by_me?: boolean
}
export const appListContract = base
.route({
path: '/apps',
method: 'GET',
})
.input(type<{
query?: AppListQuery
}>())
.output(type<AppListResponse>())
export const appDeleteContract = base
.route({
path: '/apps/{appId}',

View File

@ -15,13 +15,13 @@ export type WorkflowCommentList = {
position_y: number
content: string
created_by: string
created_by_account: UserProfile
created_by_account: UserProfile | null
created_at: number
updated_at: number
resolved: boolean
resolved_by?: string
resolved_by_account?: UserProfile
resolved_at?: number
resolved_by?: string | null
resolved_by_account?: UserProfile | null
resolved_at?: number | null
mention_count: number
reply_count: number
participants: UserProfile[]
@ -47,59 +47,59 @@ export type WorkflowCommentDetail = {
position_y: number
content: string
created_by: string
created_by_account: UserProfile
created_by_account: UserProfile | null
created_at: number
updated_at: number
resolved: boolean
resolved_by?: string
resolved_by_account?: UserProfile
resolved_at?: number
resolved_by?: string | null
resolved_by_account?: UserProfile | null
resolved_at?: number | null
replies: WorkflowCommentDetailReply[]
mentions: WorkflowCommentDetailMention[]
}
export type WorkflowCommentCreateRes = {
type WorkflowCommentCreateRes = {
id: string
created_at: string
created_at: number
}
export type WorkflowCommentUpdateRes = {
type WorkflowCommentUpdateRes = {
id: string
updated_at: string
updated_at: number
}
export type WorkflowCommentResolveRes = {
type WorkflowCommentResolveRes = {
id: string
resolved: boolean
resolved_by: string
resolved_at: number
}
export type WorkflowCommentReplyCreateRes = {
type WorkflowCommentReplyCreateRes = {
id: string
created_at: string
created_at: number
}
export type WorkflowCommentReplyUpdateRes = {
type WorkflowCommentReplyUpdateRes = {
id: string
updated_at: string
updated_at: number
}
export type CreateCommentParams = {
type CreateCommentParams = {
position_x: number
position_y: number
content: string
mentioned_user_ids?: string[]
}
export type UpdateCommentParams = {
type UpdateCommentParams = {
content: string
position_x?: number
position_y?: number
mentioned_user_ids?: string[]
}
export type CreateReplyParams = {
type CreateReplyParams = {
content: string
mentioned_user_ids?: string[]
}

View File

@ -1,7 +1,7 @@
import type { InferContractRouterInputs } from '@orpc/contract'
import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen'
import { accountAvatarContract } from './console/account'
import { appDeleteContract, workflowOnlineUsersContract } from './console/apps'
import { appDeleteContract, appListContract, workflowOnlineUsersContract } from './console/apps'
import { bindPartnerStackContract, invoicesContract } from './console/billing'
import {
exploreAppDetailContract,
@ -61,6 +61,7 @@ export const consoleRouterContract = {
},
systemFeatures: systemFeaturesContract,
apps: {
list: appListContract,
deleteApp: appDeleteContract,
workflowOnlineUsers: workflowOnlineUsersContract,
},

View File

@ -1,40 +1,13 @@
import type { TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type'
import type { AppDetailResponse, AppListResponse, CreateApiKeyResponse, DSLImportMode, DSLImportResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, WebhookTriggerResponse, WorkflowOnlineUser } from '@/models/app'
import type { AppDetailResponse, AppListResponse, CreateApiKeyResponse, DSLImportMode, DSLImportResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, WebhookTriggerResponse } from '@/models/app'
import type { CommonResponse } from '@/models/common'
import type { AppIconType, AppModeEnum, ModelConfig } from '@/types/app'
import { del, get, patch, post, put } from './base'
import { consoleClient } from './client'
export const fetchAppList = ({ url, params }: { url: string, params?: Record<string, any> }): Promise<AppListResponse> => {
return get<AppListResponse>(url, { params })
}
export const fetchWorkflowOnlineUsers = async ({ appIds }: { appIds: string[] }): Promise<Record<string, WorkflowOnlineUser[]>> => {
if (!appIds.length)
return {}
const response = await consoleClient.apps.workflowOnlineUsers({
body: { app_ids: appIds },
})
if (!response?.data)
return {}
if (Array.isArray(response.data)) {
return response.data.reduce<Record<string, WorkflowOnlineUser[]>>((acc, item) => {
if (item?.app_id)
acc[item.app_id] = item.users || []
return acc
}, {})
}
return Object.entries(response.data).reduce<Record<string, WorkflowOnlineUser[]>>((acc, [appId, users]) => {
if (appId)
acc[appId] = users || []
return acc
}, {})
}
export const fetchAppDetail = ({ url, id }: { url: string, id: string }): Promise<AppDetailResponse> => {
return get<AppDetailResponse>(`${url}/${id}`)
}

View File

@ -4,7 +4,6 @@ import type {
AppDailyConversationsResponse,
AppDailyEndUsersResponse,
AppDailyMessagesResponse,
AppListResponse,
AppStatisticsResponse,
AppTokenCostsResponse,
AppVoicesListResponse,
@ -12,66 +11,20 @@ import type {
} from '@/models/app'
import type { App } from '@/types/app'
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { consoleClient, consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { get, post } from './base'
const NAME_SPACE = 'apps'
type AppListParams = {
page?: number
limit?: number
name?: string
mode?: AppModeEnum | 'all'
tag_ids?: string[]
is_created_by_me?: boolean
}
type DateRangeParams = {
start?: string
end?: string
}
// Allowed app modes for filtering; defined at module scope to avoid re-creating on every call
const allowedModes = new Set<AppModeEnum | 'all'>([
'all',
AppModeEnum.WORKFLOW,
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.CHAT,
AppModeEnum.AGENT_CHAT,
AppModeEnum.COMPLETION,
])
const normalizeAppListParams = (params: AppListParams) => {
const {
page = 1,
limit = 30,
name = '',
mode,
tag_ids,
is_created_by_me,
} = params
const safeMode = allowedModes.has((mode as any)) ? mode : undefined
return {
page,
limit,
name,
...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}),
...(tag_ids?.length ? { tag_ids } : {}),
...(is_created_by_me ? { is_created_by_me } : {}),
}
}
const appListKey = (params: AppListParams) => [NAME_SPACE, 'list', params]
const useAppFullListKey = [NAME_SPACE, 'full-list']
export const useGenerateRuleTemplate = (type: GeneratorType, disabled?: boolean) => {
@ -95,32 +48,11 @@ export const useAppDetail = (appID: string) => {
})
}
export const useAppList = (params: AppListParams, options?: { enabled?: boolean }) => {
const normalizedParams = normalizeAppListParams(params)
return useQuery<AppListResponse>({
queryKey: appListKey(normalizedParams),
queryFn: () => get<AppListResponse>('/apps', { params: normalizedParams }),
...options,
})
}
export const useInfiniteAppList = (params: AppListParams, options?: { enabled?: boolean }) => {
const normalizedParams = normalizeAppListParams(params)
return useInfiniteQuery<AppListResponse>({
queryKey: appListKey(normalizedParams),
queryFn: ({ pageParam = normalizedParams.page }) => get<AppListResponse>('/apps', { params: { ...normalizedParams, page: pageParam } }),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: normalizedParams.page,
placeholderData: keepPreviousData,
...options,
})
}
export const useInvalidateAppList = () => {
const queryClient = useQueryClient()
return () => {
queryClient.invalidateQueries({
queryKey: [NAME_SPACE, 'list'],
queryKey: consoleQuery.apps.list.key(),
})
}
}
@ -138,7 +70,7 @@ export const useDeleteAppMutation = () => {
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: [NAME_SPACE, 'list'],
queryKey: consoleQuery.apps.list.key(),
}),
queryClient.invalidateQueries({
queryKey: useAppFullListKey,

View File

@ -1,103 +0,0 @@
import type {
CreateCommentParams as ContractCreateCommentParams,
CreateReplyParams as ContractCreateReplyParams,
UpdateCommentParams as ContractUpdateCommentParams,
UserProfile as ContractUserProfile,
WorkflowCommentCreateRes as ContractWorkflowCommentCreateRes,
WorkflowCommentDetail as ContractWorkflowCommentDetail,
WorkflowCommentDetailReply as ContractWorkflowCommentDetailReply,
WorkflowCommentList as ContractWorkflowCommentList,
WorkflowCommentReplyCreateRes as ContractWorkflowCommentReplyCreateRes,
WorkflowCommentReplyUpdateRes as ContractWorkflowCommentReplyUpdateRes,
WorkflowCommentResolveRes as ContractWorkflowCommentResolveRes,
WorkflowCommentUpdateRes as ContractWorkflowCommentUpdateRes,
} from '@/contract/console/workflow-comment'
import type { CommonResponse } from '@/models/common'
import { consoleClient } from './client'
type CreateCommentParams = ContractCreateCommentParams
type CreateReplyParams = ContractCreateReplyParams
type UpdateCommentParams = ContractUpdateCommentParams
export type UserProfile = ContractUserProfile
type WorkflowCommentCreateRes = ContractWorkflowCommentCreateRes
export type WorkflowCommentDetail = ContractWorkflowCommentDetail
export type WorkflowCommentDetailReply = ContractWorkflowCommentDetailReply
export type WorkflowCommentList = ContractWorkflowCommentList
type WorkflowCommentReplyCreateRes = ContractWorkflowCommentReplyCreateRes
type WorkflowCommentReplyUpdateRes = ContractWorkflowCommentReplyUpdateRes
type WorkflowCommentResolveRes = ContractWorkflowCommentResolveRes
type WorkflowCommentUpdateRes = ContractWorkflowCommentUpdateRes
export const fetchWorkflowComments = async (appId: string): Promise<WorkflowCommentList[]> => {
const response = await consoleClient.workflowComments.list({
params: { appId },
})
return response.data
}
export const createWorkflowComment = async (appId: string, params: CreateCommentParams): Promise<WorkflowCommentCreateRes> => {
return consoleClient.workflowComments.create({
params: { appId },
body: params,
})
}
export const fetchWorkflowComment = async (appId: string, commentId: string): Promise<WorkflowCommentDetail> => {
return consoleClient.workflowComments.detail({
params: { appId, commentId },
})
}
export const updateWorkflowComment = async (appId: string, commentId: string, params: UpdateCommentParams): Promise<WorkflowCommentUpdateRes> => {
return consoleClient.workflowComments.update({
params: { appId, commentId },
body: params,
})
}
export const deleteWorkflowComment = async (appId: string, commentId: string): Promise<CommonResponse> => {
return consoleClient.workflowComments.delete({
params: { appId, commentId },
})
}
export const resolveWorkflowComment = async (appId: string, commentId: string): Promise<WorkflowCommentResolveRes> => {
return consoleClient.workflowComments.resolve({
params: { appId, commentId },
})
}
export const createWorkflowCommentReply = async (appId: string, commentId: string, params: CreateReplyParams): Promise<WorkflowCommentReplyCreateRes> => {
return consoleClient.workflowComments.replies.create({
params: { appId, commentId },
body: params,
})
}
export const updateWorkflowCommentReply = async (appId: string, commentId: string, replyId: string, params: CreateReplyParams): Promise<WorkflowCommentReplyUpdateRes> => {
return consoleClient.workflowComments.replies.update({
params: {
appId,
commentId,
replyId,
},
body: params,
})
}
export const deleteWorkflowCommentReply = async (appId: string, commentId: string, replyId: string): Promise<CommonResponse> => {
return consoleClient.workflowComments.replies.delete({
params: {
appId,
commentId,
replyId,
},
})
}
export const fetchMentionableUsers = async (appId: string) => {
const response = await consoleClient.workflowComments.mentionUsers({
params: { appId },
})
return response.users
}