mirror of
https://github.com/langgenius/dify.git
synced 2026-06-20 17:21:06 +08:00
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:
parent
c0431ec843
commit
995c43f3dd
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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()
|
||||
|
||||
49
web/app/components/apps/hooks/use-workflow-online-users.ts
Normal file
49
web/app/components/apps/hooks/use-workflow-online-users.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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} />
|
||||
)}
|
||||
|
||||
@ -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 />)
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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[] = []
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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[]
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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[]
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user