diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index a736fc8bc8..c8334bfd18 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -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 diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index 35d07a987d..80e7c41a9e 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -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() diff --git a/eslint-suppressions.json b/eslint-suppressions.json index bbb5cd5af9..3c86fb2b7c 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index 768420f00d..e6b83bd69d 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -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() + 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, })) diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index e480db06ea..079ea9949a 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -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() + 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, })) diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index 09c083b60b..75d4e5afa8 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -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 return (
diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index c3ce96255a..9d1b39ef06 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -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() + 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(, { searchParams }) } +type AppListInfiniteOptions = { + input: (pageParam: number) => { query: Record } + 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() diff --git a/web/app/components/apps/hooks/use-workflow-online-users.ts b/web/app/components/apps/hooks/use-workflow-online-users.ts new file mode 100644 index 0000000000..a1778306f3 --- /dev/null +++ b/web/app/components/apps/hooks/use-workflow-online-users.ts @@ -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 + +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((acc, item) => { + if (item?.app_id) + acc[item.app_id] = item.users || [] + return acc + }, {}) + } + + return Object.entries(data).reduce((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, + } +} diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index b744fe77aa..728ef38ba5 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -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 = ({ const containerRef = useRef(null) const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [droppedDSLFile, setDroppedDSLFile] = useState() - const [workflowOnlineUsersMap, setWorkflowOnlineUsersMap] = useState>({}) const setKeywords = useCallback((keywords: string) => { setQuery(prev => ({ ...prev, keywords })) }, [setQuery]) @@ -90,14 +89,14 @@ const List: FC = ({ enabled: isCurrentWorkspaceEditor, }) - const appListQueryParams = { + const appListQuery = useMemo(() => ({ 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 = ({ 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(null) const options = [ @@ -187,53 +199,23 @@ const List: FC = ({ }, [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() - 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 = ({ className={cn(!hasAnyApp && 'z-10')} /> )} - {(() => { - if (showSkeleton) - return - - if (hasAnyApp) { - return pages.flatMap(({ data: apps }) => apps).map(app => ( - - )) - } - - // No apps - show empty state - return - })()} + {showSkeleton + ? + : hasAnyApp + ? apps.map(app => ( + + )) + : } {isFetchingNextPage && ( )} diff --git a/web/app/components/header/app-nav/__tests__/index.spec.tsx b/web/app/components/header/app-nav/__tests__/index.spec.tsx index 03f8edfacf..c2b0207b83 100644 --- a/web/app/components/header/app-nav/__tests__/index.spec.tsx +++ b/web/app/components/header/app-nav/__tests__/index.spec.tsx @@ -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() + 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) mockUseAppContext.mockReturnValue({ isCurrentWorkspaceEditor: options?.isEditor ?? false } as ReturnType) 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) + } as ReturnType) return { refetch, fetchNextPage } } @@ -164,6 +184,23 @@ describe('AppNav', () => { setupDefaultMocks() }) + it('should configure paged app list query options', () => { + setupDefaultMocks() + render() + + 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) - mockUseInfiniteAppList.mockReturnValue({ + mockUseInfiniteQuery.mockReturnValue({ data: undefined, fetchNextPage: vi.fn(), hasNextPage: false, isFetchingNextPage: false, refetch: vi.fn(), - } as unknown as ReturnType) + } as unknown as ReturnType) // Act render() diff --git a/web/app/components/header/app-nav/index.tsx b/web/app/components/header/app-nav/index.tsx index 2e7a77a891..a65477e4df 100644 --- a/web/app/components/header/app-nav/index.tsx +++ b/web/app/components/header/app-nav/index.tsx @@ -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([]) 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(() => { + 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 ( <> diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx index d67be38ab4..38b5324fa7 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx @@ -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() + return { + ...actual, + useInfiniteQuery: () => ({ + data: mockAppListData, + isLoading: mockIsLoading, + isFetchingNextPage: mockIsFetchingNextPage, + fetchNextPage: mockFetchNextPage, + hasNextPage: mockHasNextPage, + }), + } +}) + // Allow configurable mock data for useAppWorkflow let mockWorkflowData: Record | undefined | null let mockWorkflowLoading = false @@ -323,6 +342,11 @@ const renderWithQueryClient = (ui: React.ReactElement) => { ) } +type AppSelectorInfiniteOptions = { + input: (pageParam: number) => { query: Record } + getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined +} + // Mock data factories const createMockApp = (overrides: Record = {}): 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() + + 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( = ({ const [isShow, setIsShow] = useState(false) const [searchText, setSearchText] = useState('') + const appListQuery = useMemo(() => ({ + 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(() => { diff --git a/web/app/components/workflow/comment/comment-icon.spec.tsx b/web/app/components/workflow/comment/comment-icon.spec.tsx index aee8c64fa3..eeca5bb4e6 100644 --- a/web/app/components/workflow/comment/comment-icon.spec.tsx +++ b/web/app/components/workflow/comment/comment-icon.spec.tsx @@ -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' diff --git a/web/app/components/workflow/comment/comment-icon.tsx b/web/app/components/workflow/comment/comment-icon.tsx index 7f005f3465..7270cd3ac9 100644 --- a/web/app/components/workflow/comment/comment-icon.tsx +++ b/web/app/components/workflow/comment/comment-icon.tsx @@ -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' diff --git a/web/app/components/workflow/comment/comment-preview.spec.tsx b/web/app/components/workflow/comment/comment-preview.spec.tsx index d411c67ecd..a83303cb0d 100644 --- a/web/app/components/workflow/comment/comment-preview.spec.tsx +++ b/web/app/components/workflow/comment/comment-preview.spec.tsx @@ -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 const mockSetHovering = vi.fn() let capturedUsers: UserProfile[] = [] diff --git a/web/app/components/workflow/comment/comment-preview.tsx b/web/app/components/workflow/comment/comment-preview.tsx index 5985ed848b..6aadb522a9 100644 --- a/web/app/components/workflow/comment/comment-preview.tsx +++ b/web/app/components/workflow/comment/comment-preview.tsx @@ -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 = ({ 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 = ({ comment, onClick }) => {
-
{comment.created_by_account.name}
+
{authorName}
{formatTimeFromNow(comment.updated_at * 1000)}
diff --git a/web/app/components/workflow/comment/mention-input.spec.tsx b/web/app/components/workflow/comment/mention-input.spec.tsx index 49fc7b6e87..ce53880595 100644 --- a/web/app/components/workflow/comment/mention-input.spec.tsx +++ b/web/app/components/workflow/comment/mention-input.spec.tsx @@ -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( + , + ) + + 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() + } + }) }) diff --git a/web/app/components/workflow/comment/mention-input.tsx b/web/app/components/workflow/comment/mention-input.tsx index b6a7caa055..59f3bf1249 100644 --- a/web/app/components/workflow/comment/mention-input.tsx +++ b/web/app/components/workflow/comment/mention-input.tsx @@ -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(({ value, onChange, @@ -66,7 +68,7 @@ const MentionInputInner = forwardRef(({ 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(({ 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(({ }, [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 ( diff --git a/web/app/components/workflow/comment/thread.spec.tsx b/web/app/components/workflow/comment/thread.spec.tsx index 58bb99cff0..2aa36142e9 100644 --- a/web/app/components/workflow/comment/thread.spec.tsx +++ b/web/app/components/workflow/comment/thread.spec.tsx @@ -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' diff --git a/web/app/components/workflow/comment/thread.tsx b/web/app/components/workflow/comment/thread.tsx index 34e0092372..071cc48462 100644 --- a/web/app/components/workflow/comment/thread.tsx +++ b/web/app/components/workflow/comment/thread.tsx @@ -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 { diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts index b2edfa5234..19eb1ad0e8 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts @@ -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() + }) }) diff --git a/web/app/components/workflow/hooks/use-workflow-comment.ts b/web/app/components/workflow/hooks/use-workflow-comment.ts index cdd14ceef1..54be5325e5 100644 --- a/web/app/components/workflow/hooks/use-workflow-comment.ts +++ b/web/app/components/workflow/hooks/use-workflow-comment.ts @@ -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 => Boolean(user)) const uniqueParticipantsMap = new Map() 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) diff --git a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx index 990153d308..4ccf2b1061 100644 --- a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx @@ -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() + return { + ...actual, + useInfiniteQuery: () => ({ + data: { + pages: [{ + data: mockApps, + }], + }, + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + }), + } +}) + vi.mock('@/service/use-workflow', () => ({ useAppWorkflow: () => ({ data: undefined, diff --git a/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx index 49ae2a11a8..fd3f570f02 100644 --- a/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx @@ -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: () =>
, })) @@ -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') }) }) diff --git a/web/app/components/workflow/panel/comments-panel/index.tsx b/web/app/components/workflow/panel/comments-panel/index.tsx index 05480abb2b..47f7c015ba 100644 --- a/web/app/components/workflow/panel/comments-panel/index.tsx +++ b/web/app/components/workflow/panel/comments-panel/index.tsx @@ -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 */}
-
{c.created_by_account.name}
+
{c.created_by_account?.name ?? ''}
{formatTimeFromNow(c.updated_at * 1000)}
diff --git a/web/app/components/workflow/store/workflow/comment-slice.ts b/web/app/components/workflow/store/workflow/comment-slice.ts index cc7605c285..71d439fced 100644 --- a/web/app/components/workflow/store/workflow/comment-slice.ts +++ b/web/app/components/workflow/store/workflow/comment-slice.ts @@ -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[] diff --git a/web/contract/console/apps.ts b/web/contract/console/apps.ts index 2f5f16c25e..bd9e8ed06b 100644 --- a/web/contract/console/apps.ts +++ b/web/contract/console/apps.ts @@ -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()) + export const appDeleteContract = base .route({ path: '/apps/{appId}', diff --git a/web/contract/console/workflow-comment.ts b/web/contract/console/workflow-comment.ts index a4c55a46e0..216487f8c8 100644 --- a/web/contract/console/workflow-comment.ts +++ b/web/contract/console/workflow-comment.ts @@ -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[] } diff --git a/web/contract/router.ts b/web/contract/router.ts index d45d3c000a..c1b4e1fa08 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -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, }, diff --git a/web/service/apps.ts b/web/service/apps.ts index 221e83cf39..d8e5e2136b 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -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 }): Promise => { return get(url, { params }) } -export const fetchWorkflowOnlineUsers = async ({ appIds }: { appIds: string[] }): Promise> => { - 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>((acc, item) => { - if (item?.app_id) - acc[item.app_id] = item.users || [] - return acc - }, {}) - } - - return Object.entries(response.data).reduce>((acc, [appId, users]) => { - if (appId) - acc[appId] = users || [] - return acc - }, {}) -} - export const fetchAppDetail = ({ url, id }: { url: string, id: string }): Promise => { return get(`${url}/${id}`) } diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts index f28df7bd4b..b09aa18f94 100644 --- a/web/service/use-apps.ts +++ b/web/service/use-apps.ts @@ -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([ - '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({ - queryKey: appListKey(normalizedParams), - queryFn: () => get('/apps', { params: normalizedParams }), - ...options, - }) -} - -export const useInfiniteAppList = (params: AppListParams, options?: { enabled?: boolean }) => { - const normalizedParams = normalizeAppListParams(params) - return useInfiniteQuery({ - queryKey: appListKey(normalizedParams), - queryFn: ({ pageParam = normalizedParams.page }) => get('/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, diff --git a/web/service/workflow-comment.ts b/web/service/workflow-comment.ts deleted file mode 100644 index a8debbfd15..0000000000 --- a/web/service/workflow-comment.ts +++ /dev/null @@ -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 => { - const response = await consoleClient.workflowComments.list({ - params: { appId }, - }) - return response.data -} - -export const createWorkflowComment = async (appId: string, params: CreateCommentParams): Promise => { - return consoleClient.workflowComments.create({ - params: { appId }, - body: params, - }) -} - -export const fetchWorkflowComment = async (appId: string, commentId: string): Promise => { - return consoleClient.workflowComments.detail({ - params: { appId, commentId }, - }) -} - -export const updateWorkflowComment = async (appId: string, commentId: string, params: UpdateCommentParams): Promise => { - return consoleClient.workflowComments.update({ - params: { appId, commentId }, - body: params, - }) -} - -export const deleteWorkflowComment = async (appId: string, commentId: string): Promise => { - return consoleClient.workflowComments.delete({ - params: { appId, commentId }, - }) -} - -export const resolveWorkflowComment = async (appId: string, commentId: string): Promise => { - return consoleClient.workflowComments.resolve({ - params: { appId, commentId }, - }) -} - -export const createWorkflowCommentReply = async (appId: string, commentId: string, params: CreateReplyParams): Promise => { - return consoleClient.workflowComments.replies.create({ - params: { appId, commentId }, - body: params, - }) -} - -export const updateWorkflowCommentReply = async (appId: string, commentId: string, replyId: string, params: CreateReplyParams): Promise => { - return consoleClient.workflowComments.replies.update({ - params: { - appId, - commentId, - replyId, - }, - body: params, - }) -} - -export const deleteWorkflowCommentReply = async (appId: string, commentId: string, replyId: string): Promise => { - 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 -}