mirror of
https://github.com/langgenius/dify.git
synced 2026-05-12 05:07:41 +08:00
Compare commits
2 Commits
codex/bump
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 59dab7deac | |||
| a60cb3b800 |
@ -55,9 +55,17 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
|
||||
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
|
||||
|
||||
## Navigation, Effects, And Performance
|
||||
## You Might Not Need An Effect
|
||||
|
||||
- Use Effects only to synchronize with external systems such as browser APIs, non-React widgets, subscriptions, timers, analytics that must run because the component was shown, or imperative DOM integration.
|
||||
- Do not use Effects to transform props or state for rendering. Calculate derived values during render, and use `useMemo` only when the calculation is actually expensive.
|
||||
- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known.
|
||||
- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render.
|
||||
- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary.
|
||||
- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components.
|
||||
- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow.
|
||||
|
||||
## Navigation And Performance
|
||||
|
||||
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
|
||||
- Treat `useEffect` as a last resort. First try deriving values during render, moving event-driven work into handlers, or using existing hooks/APIs for persistence, subscriptions, media queries, timers, and DOM sync.
|
||||
- Do not use `useEffect` directly in components. If unavoidable, encapsulate it in a purpose-built hook so the component consumes a declarative API.
|
||||
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
"""Workflow comment models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import Index, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from models.base import TypeBase
|
||||
|
||||
from .account import Account
|
||||
from .base import Base, gen_uuidv7_string
|
||||
from .base import gen_uuidv7_string
|
||||
from .engine import db
|
||||
from .types import StringUUID
|
||||
|
||||
|
||||
class WorkflowComment(Base):
|
||||
class WorkflowComment(TypeBase):
|
||||
"""Workflow comment model for canvas commenting functionality.
|
||||
|
||||
Comments are associated with apps rather than specific workflow versions,
|
||||
@ -42,27 +45,33 @@ class WorkflowComment(Base):
|
||||
Index("workflow_comments_created_at_idx", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string)
|
||||
id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False)
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
position_x: Mapped[float] = mapped_column(sa.Float)
|
||||
position_y: Mapped[float] = mapped_column(sa.Float)
|
||||
content: Mapped[str] = mapped_column(sa.Text, nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
|
||||
sa.DateTime,
|
||||
nullable=False,
|
||||
server_default=func.current_timestamp(),
|
||||
onupdate=func.current_timestamp(),
|
||||
init=False,
|
||||
)
|
||||
resolved: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
|
||||
resolved_by: Mapped[str | None] = mapped_column(StringUUID)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(sa.DateTime, default=None)
|
||||
resolved_by: Mapped[str | None] = mapped_column(StringUUID, default=None)
|
||||
|
||||
resolved: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False)
|
||||
# Relationships
|
||||
replies: Mapped[list["WorkflowCommentReply"]] = relationship(
|
||||
"WorkflowCommentReply", back_populates="comment", cascade="all, delete-orphan"
|
||||
replies: Mapped[list[WorkflowCommentReply]] = relationship(
|
||||
lambda: WorkflowCommentReply, back_populates="comment", cascade="all, delete-orphan", init=False
|
||||
)
|
||||
mentions: Mapped[list["WorkflowCommentMention"]] = relationship(
|
||||
"WorkflowCommentMention", back_populates="comment", cascade="all, delete-orphan"
|
||||
mentions: Mapped[list[WorkflowCommentMention]] = relationship(
|
||||
lambda: WorkflowCommentMention, back_populates="comment", cascade="all, delete-orphan", init=False
|
||||
)
|
||||
|
||||
@property
|
||||
@ -131,7 +140,7 @@ class WorkflowComment(Base):
|
||||
return participants
|
||||
|
||||
|
||||
class WorkflowCommentReply(Base):
|
||||
class WorkflowCommentReply(TypeBase):
|
||||
"""Workflow comment reply model.
|
||||
|
||||
Attributes:
|
||||
@ -149,18 +158,24 @@ class WorkflowCommentReply(Base):
|
||||
Index("comment_replies_created_at_idx", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string)
|
||||
id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False)
|
||||
comment_id: Mapped[str] = mapped_column(
|
||||
StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
content: Mapped[str] = mapped_column(sa.Text, nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
|
||||
sa.DateTime,
|
||||
nullable=False,
|
||||
server_default=func.current_timestamp(),
|
||||
onupdate=func.current_timestamp(),
|
||||
init=False,
|
||||
)
|
||||
# Relationships
|
||||
comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="replies")
|
||||
comment: Mapped[WorkflowComment] = relationship(lambda: WorkflowComment, back_populates="replies", init=False)
|
||||
|
||||
@property
|
||||
def created_by_account(self):
|
||||
@ -174,7 +189,7 @@ class WorkflowCommentReply(Base):
|
||||
self._created_by_account_cache = account
|
||||
|
||||
|
||||
class WorkflowCommentMention(Base):
|
||||
class WorkflowCommentMention(TypeBase):
|
||||
"""Workflow comment mention model.
|
||||
|
||||
Mentions are only for internal accounts since end users
|
||||
@ -194,18 +209,18 @@ class WorkflowCommentMention(Base):
|
||||
Index("comment_mentions_user_idx", "mentioned_user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string)
|
||||
id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False)
|
||||
comment_id: Mapped[str] = mapped_column(
|
||||
StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
reply_id: Mapped[str | None] = mapped_column(
|
||||
StringUUID, sa.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
reply_id: Mapped[str | None] = mapped_column(
|
||||
StringUUID, sa.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True, default=None
|
||||
)
|
||||
|
||||
# Relationships
|
||||
comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="mentions")
|
||||
reply: Mapped[Optional["WorkflowCommentReply"]] = relationship("WorkflowCommentReply")
|
||||
comment: Mapped[WorkflowComment] = relationship(lambda: WorkflowComment, back_populates="mentions", init=False)
|
||||
reply: Mapped[WorkflowCommentReply | None] = relationship(lambda: WorkflowCommentReply, init=False)
|
||||
|
||||
@property
|
||||
def mentioned_user_account(self):
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.14.1"
|
||||
version = "1.14.0"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@ -4,7 +4,15 @@ from models.comment import WorkflowComment, WorkflowCommentMention, WorkflowComm
|
||||
|
||||
|
||||
def test_workflow_comment_account_properties_and_cache() -> None:
|
||||
comment = WorkflowComment(created_by="user-1", resolved_by="user-2", content="hello", position_x=1, position_y=2)
|
||||
comment = WorkflowComment(
|
||||
created_by="user-1",
|
||||
resolved_by="user-2",
|
||||
content="hello",
|
||||
position_x=1,
|
||||
position_y=2,
|
||||
tenant_id="xxx",
|
||||
app_id="yyy",
|
||||
)
|
||||
created_account = Mock(id="user-1")
|
||||
resolved_account = Mock(id="user-2")
|
||||
|
||||
@ -21,6 +29,8 @@ def test_workflow_comment_account_properties_and_cache() -> None:
|
||||
get_mock.assert_not_called()
|
||||
|
||||
comment_without_resolver = WorkflowComment(
|
||||
tenant_id="xxx",
|
||||
app_id="yyy",
|
||||
created_by="user-1",
|
||||
resolved_by=None,
|
||||
content="hello",
|
||||
@ -37,7 +47,15 @@ def test_workflow_comment_counts_and_participants() -> None:
|
||||
reply_2 = WorkflowCommentReply(comment_id="comment-1", content="reply-2", created_by="user-2")
|
||||
mention_1 = WorkflowCommentMention(comment_id="comment-1", mentioned_user_id="user-3")
|
||||
mention_2 = WorkflowCommentMention(comment_id="comment-1", mentioned_user_id="user-4")
|
||||
comment = WorkflowComment(created_by="user-1", resolved_by=None, content="hello", position_x=1, position_y=2)
|
||||
comment = WorkflowComment(
|
||||
created_by="user-1",
|
||||
resolved_by=None,
|
||||
content="hello",
|
||||
position_x=1,
|
||||
position_y=2,
|
||||
tenant_id="xxx",
|
||||
app_id="yyy",
|
||||
)
|
||||
comment.replies = [reply_1, reply_2]
|
||||
comment.mentions = [mention_1, mention_2]
|
||||
|
||||
@ -63,7 +81,15 @@ def test_workflow_comment_counts_and_participants() -> None:
|
||||
def test_workflow_comment_participants_use_cached_accounts() -> None:
|
||||
reply = WorkflowCommentReply(comment_id="comment-1", content="reply-1", created_by="user-2")
|
||||
mention = WorkflowCommentMention(comment_id="comment-1", mentioned_user_id="user-3")
|
||||
comment = WorkflowComment(created_by="user-1", resolved_by=None, content="hello", position_x=1, position_y=2)
|
||||
comment = WorkflowComment(
|
||||
created_by="user-1",
|
||||
resolved_by=None,
|
||||
content="hello",
|
||||
position_x=1,
|
||||
position_y=2,
|
||||
tenant_id="xxx",
|
||||
app_id="yyy",
|
||||
)
|
||||
comment.replies = [reply]
|
||||
comment.mentions = [mention]
|
||||
|
||||
|
||||
2
api/uv.lock
generated
2
api/uv.lock
generated
@ -1292,7 +1292,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.14.1"
|
||||
version = "1.14.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
@ -220,7 +220,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.0
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@ -264,7 +264,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.0
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@ -290,7 +290,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.0
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@ -333,7 +333,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.0
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@ -366,7 +366,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.14.1
|
||||
image: langgenius/dify-web:1.14.0
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
|
||||
@ -226,7 +226,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.0
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@ -270,7 +270,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.0
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@ -296,7 +296,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.0
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@ -339,7 +339,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.0
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@ -372,7 +372,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.14.1
|
||||
image: langgenius/dify-web:1.14.0
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
|
||||
@ -48,16 +48,24 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetQuery = vi.fn()
|
||||
const mockSetKeywords = vi.fn()
|
||||
const mockSetTagIDs = vi.fn()
|
||||
const mockSetIsCreatedByMe = vi.fn()
|
||||
const mockSetCategory = vi.fn()
|
||||
const mockQueryState = {
|
||||
category: 'all',
|
||||
tagIDs: [] as string[],
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
}
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
default: () => ({
|
||||
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
|
||||
useAppsQueryState: () => ({
|
||||
query: mockQueryState,
|
||||
setQuery: mockSetQuery,
|
||||
setCategory: mockSetCategory,
|
||||
setKeywords: mockSetKeywords,
|
||||
setTagIDs: mockSetTagIDs,
|
||||
setIsCreatedByMe: mockSetIsCreatedByMe,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -244,6 +252,7 @@ describe('List', () => {
|
||||
mockServiceState.hasNextPage = false
|
||||
mockServiceState.isLoading = false
|
||||
mockServiceState.isFetchingNextPage = false
|
||||
mockQueryState.category = 'all'
|
||||
mockQueryState.tagIDs = []
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.isCreatedByMe = false
|
||||
@ -317,25 +326,21 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should update URL when workflow tab is clicked', async () => {
|
||||
const { onUrlUpdate } = renderList()
|
||||
it('should update category when workflow tab is clicked', () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
|
||||
expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should update URL when all tab is clicked', async () => {
|
||||
const { onUrlUpdate } = renderList('?category=workflow')
|
||||
it('should update category when all tab is clicked', () => {
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
|
||||
// nuqs removes the default value ('all') from URL params
|
||||
expect(lastCall.searchParams.has('category')).toBe(false)
|
||||
expect(mockSetCategory).toHaveBeenCalledWith('all')
|
||||
})
|
||||
})
|
||||
|
||||
@ -351,7 +356,7 @@ describe('List', () => {
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('test search')
|
||||
})
|
||||
|
||||
it('should handle search clear button click', () => {
|
||||
@ -364,7 +369,7 @@ describe('List', () => {
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
|
||||
@ -373,8 +378,9 @@ describe('List', () => {
|
||||
mockQueryState.tagIDs = ['tag-1']
|
||||
mockQueryState.keywords = 'sales'
|
||||
mockQueryState.isCreatedByMe = true
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
|
||||
renderList('?category=workflow')
|
||||
renderList()
|
||||
|
||||
const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions
|
||||
|
||||
@ -412,7 +418,7 @@ describe('List', () => {
|
||||
const checkbox = screen.getByTestId('checkbox-undefined')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -506,8 +512,8 @@ describe('List', () => {
|
||||
expect(screen.getByText('app.types.completion'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update URL for each app type tab click', async () => {
|
||||
const { onUrlUpdate } = renderList()
|
||||
it('should update category for each app type tab click', () => {
|
||||
renderList()
|
||||
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
@ -518,11 +524,9 @@ describe('List', () => {
|
||||
]
|
||||
|
||||
for (const { mode, text } of appTypeTexts) {
|
||||
onUrlUpdate.mockClear()
|
||||
mockSetCategory.mockClear()
|
||||
fireEvent.click(screen.getByText(text))
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(mode)
|
||||
expect(mockSetCategory).toHaveBeenCalledWith(mode)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
1
web/app/components/apps/constants.ts
Normal file
1
web/app/components/apps/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const APP_LIST_SEARCH_DEBOUNCE_MS = 500
|
||||
@ -1,6 +1,8 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { renderHookWithNuqs } from '@/test/nuqs-testing'
|
||||
import useAppsQueryState from '../use-apps-query-state'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../../constants'
|
||||
import { useAppsQueryState } from '../use-apps-query-state'
|
||||
|
||||
const renderWithAdapter = (searchParams = '') => {
|
||||
return renderHookWithNuqs(() => useAppsQueryState(), { searchParams })
|
||||
@ -11,214 +13,161 @@ describe('useAppsQueryState', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should expose query and setQuery when initialized', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
it('should expose app list query state actions', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
expect(result.current.query).toBeDefined()
|
||||
expect(typeof result.current.setQuery).toBe('function')
|
||||
expect(result.current.query).toEqual({
|
||||
category: 'all',
|
||||
tagIDs: [],
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
})
|
||||
expect(typeof result.current.setCategory).toBe('function')
|
||||
expect(typeof result.current.setKeywords).toBe('function')
|
||||
expect(typeof result.current.setTagIDs).toBe('function')
|
||||
expect(typeof result.current.setIsCreatedByMe).toBe('function')
|
||||
})
|
||||
|
||||
it('should default to empty filters when search params are missing', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
it('should parse app list filters from URL', () => {
|
||||
const { result } = renderWithAdapter(
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true',
|
||||
)
|
||||
|
||||
expect(result.current.query.tagIDs).toBeUndefined()
|
||||
expect(result.current.query.keywords).toBeUndefined()
|
||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
||||
expect(result.current.query).toEqual({
|
||||
category: AppModeEnum.WORKFLOW,
|
||||
tagIDs: ['tag1', 'tag2'],
|
||||
keywords: 'search term',
|
||||
isCreatedByMe: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Parsing search params', () => {
|
||||
it('should parse tagIDs when URL includes tagIDs', () => {
|
||||
const { result } = renderWithAdapter('?tagIDs=tag1;tag2;tag3')
|
||||
it('should update category URL state', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3'])
|
||||
act(() => {
|
||||
result.current.setCategory(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should parse keywords when URL includes keywords', () => {
|
||||
const { result } = renderWithAdapter('?keywords=search+term')
|
||||
|
||||
expect(result.current.query.keywords).toBe('search term')
|
||||
})
|
||||
|
||||
it('should parse isCreatedByMe when URL includes true value', () => {
|
||||
const { result } = renderWithAdapter('?isCreatedByMe=true')
|
||||
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
})
|
||||
|
||||
it('should parse all params when URL includes multiple filters', () => {
|
||||
const { result } = renderWithAdapter(
|
||||
'?tagIDs=tag1;tag2&keywords=test&isCreatedByMe=true',
|
||||
)
|
||||
|
||||
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
|
||||
expect(result.current.query.keywords).toBe('test')
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
})
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.category).toBe(AppModeEnum.WORKFLOW)
|
||||
expect(update.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
describe('Updating query state', () => {
|
||||
it('should update keywords when setQuery receives keywords', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
it('should remove category from URL when set to all', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?category=workflow')
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ keywords: 'new search' })
|
||||
})
|
||||
|
||||
expect(result.current.query.keywords).toBe('new search')
|
||||
act(() => {
|
||||
result.current.setCategory('all')
|
||||
})
|
||||
|
||||
it('should update tagIDs when setQuery receives tagIDs', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
|
||||
})
|
||||
|
||||
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
|
||||
})
|
||||
|
||||
it('should update isCreatedByMe when setQuery receives true', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ isCreatedByMe: true })
|
||||
})
|
||||
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
})
|
||||
|
||||
it('should support partial updates when setQuery uses callback', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ keywords: 'initial' })
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
|
||||
})
|
||||
|
||||
expect(result.current.query.keywords).toBe('initial')
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
})
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.category).toBe('all')
|
||||
expect(update.searchParams.has('category')).toBe(false)
|
||||
})
|
||||
|
||||
describe('URL synchronization', () => {
|
||||
it('should sync keywords to URL when keywords change', async () => {
|
||||
it('should update keywords state immediately while debouncing URL writes', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ keywords: 'search' })
|
||||
result.current.setKeywords('search')
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
|
||||
expect(result.current.query.keywords).toBe('search')
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(APP_LIST_SEARCH_DEBOUNCE_MS + 100)
|
||||
})
|
||||
|
||||
expect(onUrlUpdate).toHaveBeenCalled()
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(update.searchParams.get('keywords')).toBe('search')
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('should sync tagIDs to URL when tagIDs change', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
|
||||
expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2')
|
||||
})
|
||||
|
||||
it('should sync isCreatedByMe to URL when enabled', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ isCreatedByMe: true })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
|
||||
expect(update.searchParams.get('isCreatedByMe')).toBe('true')
|
||||
})
|
||||
|
||||
it('should remove keywords from URL when keywords are cleared', async () => {
|
||||
it('should remove keywords from URL when cleared', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?keywords=existing')
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ keywords: '' })
|
||||
result.current.setKeywords('')
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
|
||||
expect(result.current.query.keywords).toBe('')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(APP_LIST_SEARCH_DEBOUNCE_MS + 100)
|
||||
})
|
||||
|
||||
expect(onUrlUpdate).toHaveBeenCalled()
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(update.searchParams.has('keywords')).toBe(false)
|
||||
})
|
||||
|
||||
it('should remove tagIDs from URL when tagIDs are empty', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?tagIDs=tag1;tag2')
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ tagIDs: [] })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
|
||||
expect(update.searchParams.has('tagIDs')).toBe(false)
|
||||
})
|
||||
|
||||
it('should remove isCreatedByMe from URL when disabled', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ isCreatedByMe: false })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
|
||||
expect(update.searchParams.has('isCreatedByMe')).toBe(false)
|
||||
})
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should treat empty tagIDs as empty list when URL param is empty', () => {
|
||||
const { result } = renderWithAdapter('?tagIDs=')
|
||||
it('should update tag filter URL state', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
expect(result.current.query.tagIDs).toEqual([])
|
||||
act(() => {
|
||||
result.current.setTagIDs(['tag1', 'tag2'])
|
||||
})
|
||||
|
||||
it('should treat empty keywords as undefined when URL param is empty', () => {
|
||||
const { result } = renderWithAdapter('?keywords=')
|
||||
|
||||
expect(result.current.query.keywords).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should decode keywords with spaces when URL contains encoded spaces', () => {
|
||||
const { result } = renderWithAdapter('?keywords=test+with+spaces')
|
||||
|
||||
expect(result.current.query.keywords).toBe('test with spaces')
|
||||
})
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
|
||||
expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2')
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
it('should keep accumulated filters when updates are sequential', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
it('should remove tagIDs from URL when empty', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?tagIDs=tag1;tag2')
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ keywords: 'first' })
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
|
||||
})
|
||||
|
||||
expect(result.current.query.keywords).toBe('first')
|
||||
expect(result.current.query.tagIDs).toEqual(['tag1'])
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
act(() => {
|
||||
result.current.setTagIDs([])
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.tagIDs).toEqual([])
|
||||
expect(update.searchParams.has('tagIDs')).toBe(false)
|
||||
})
|
||||
|
||||
it('should update created-by-me URL state', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setIsCreatedByMe(true)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
expect(update.searchParams.get('isCreatedByMe')).toBe('true')
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
it('should remove isCreatedByMe from URL when disabled', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
|
||||
|
||||
act(() => {
|
||||
result.current.setIsCreatedByMe(false)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
||||
expect(update.searchParams.has('isCreatedByMe')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,57 +1,56 @@
|
||||
import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { debounce, parseAsArrayOf, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { AppModes } from '@/types/app'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
|
||||
|
||||
type AppsQuery = {
|
||||
tagIDs?: string[]
|
||||
keywords?: string
|
||||
isCreatedByMe?: boolean
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
export const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
const normalizeKeywords = (value: string | null) => value || undefined
|
||||
|
||||
function useAppsQueryState() {
|
||||
const [urlQuery, setUrlQuery] = useQueryStates(
|
||||
{
|
||||
tagIDs: parseAsArrayOf(parseAsString, ';'),
|
||||
keywords: parseAsString,
|
||||
isCreatedByMe: parseAsBoolean,
|
||||
},
|
||||
{
|
||||
history: 'push',
|
||||
},
|
||||
)
|
||||
|
||||
const query = useMemo<AppsQuery>(() => ({
|
||||
tagIDs: urlQuery.tagIDs ?? undefined,
|
||||
keywords: normalizeKeywords(urlQuery.keywords),
|
||||
isCreatedByMe: urlQuery.isCreatedByMe ?? false,
|
||||
}), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs])
|
||||
|
||||
const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => {
|
||||
const buildPatch = (patch: AppsQuery) => {
|
||||
const result: Partial<typeof urlQuery> = {}
|
||||
if ('tagIDs' in patch)
|
||||
result.tagIDs = patch.tagIDs && patch.tagIDs.length > 0 ? patch.tagIDs : null
|
||||
if ('keywords' in patch)
|
||||
result.keywords = patch.keywords ? patch.keywords : null
|
||||
if ('isCreatedByMe' in patch)
|
||||
result.isCreatedByMe = patch.isCreatedByMe ? true : null
|
||||
return result
|
||||
}
|
||||
|
||||
if (typeof next === 'function') {
|
||||
setUrlQuery(prev => buildPatch(next({
|
||||
tagIDs: prev.tagIDs ?? undefined,
|
||||
keywords: normalizeKeywords(prev.keywords),
|
||||
isCreatedByMe: prev.isCreatedByMe ?? false,
|
||||
})))
|
||||
return
|
||||
}
|
||||
|
||||
setUrlQuery(buildPatch(next))
|
||||
}, [setUrlQuery])
|
||||
|
||||
return useMemo(() => ({ query, setQuery }), [query, setQuery])
|
||||
const appListQueryParsers = {
|
||||
category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' }),
|
||||
tagIDs: parseAsArrayOf(parseAsString, ';')
|
||||
.withDefault([])
|
||||
.withOptions({ history: 'push' }),
|
||||
keywords: parseAsString.withDefault('').withOptions({
|
||||
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
|
||||
}),
|
||||
isCreatedByMe: parseAsBoolean
|
||||
.withDefault(false)
|
||||
.withOptions({ history: 'push' }),
|
||||
}
|
||||
|
||||
export default useAppsQueryState
|
||||
export function useAppsQueryState() {
|
||||
const [query, setQuery] = useQueryStates(appListQueryParsers)
|
||||
|
||||
const setCategory = useCallback((category: AppListCategory) => {
|
||||
setQuery({ category })
|
||||
}, [setQuery])
|
||||
|
||||
const setKeywords = useCallback((keywords: string) => {
|
||||
setQuery({ keywords })
|
||||
}, [setQuery])
|
||||
|
||||
const setTagIDs = useCallback((tagIDs: string[]) => {
|
||||
setQuery({ tagIDs })
|
||||
}, [setQuery])
|
||||
|
||||
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
|
||||
setQuery({ isCreatedByMe })
|
||||
}, [setQuery])
|
||||
|
||||
return useMemo(() => ({
|
||||
query,
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setTagIDs,
|
||||
setIsCreatedByMe,
|
||||
}), [query, setCategory, setKeywords, setTagIDs, setIsCreatedByMe])
|
||||
}
|
||||
|
||||
@ -4,8 +4,7 @@ import type { FC } from 'react'
|
||||
import type { AppListQuery } from '@/contract/console/apps'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
@ -18,12 +17,13 @@ import { CheckModal } from '@/hooks/use-pay'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { AppModeEnum, AppModes } from '@/types/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import useAppsQueryStateHook from './hooks/use-apps-query-state'
|
||||
import { isAppListCategory, 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'
|
||||
@ -35,18 +35,6 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
|
||||
type Props = {
|
||||
controlRefreshList?: number
|
||||
}
|
||||
@ -56,28 +44,21 @@ const List: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
'category',
|
||||
parseAsAppListCategory,
|
||||
)
|
||||
|
||||
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
|
||||
const appsQuery = useAppsQueryStateHook()
|
||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = appsQuery
|
||||
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
|
||||
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
|
||||
const [searchKeywords, setSearchKeywords] = useState(keywords)
|
||||
const {
|
||||
query: { category, tagIDs, keywords, isCreatedByMe },
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setTagIDs,
|
||||
setIsCreatedByMe,
|
||||
} = useAppsQueryState()
|
||||
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
|
||||
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
|
||||
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
|
||||
const setKeywords = useCallback((keywords: string) => {
|
||||
setQuery(prev => ({ ...prev, keywords }))
|
||||
}, [setQuery])
|
||||
const setTagIDs = useCallback((tagIDs: string[]) => {
|
||||
setQuery(prev => ({ ...prev, tagIDs }))
|
||||
}, [setQuery])
|
||||
|
||||
const handleDSLFileDropped = useCallback((file: File) => {
|
||||
setDroppedDSLFile(file)
|
||||
@ -93,11 +74,11 @@ const List: FC<Props> = ({
|
||||
const appListQuery = useMemo<AppListQuery>(() => ({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
name: searchKeywords,
|
||||
name: debouncedKeywords,
|
||||
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
|
||||
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
|
||||
...(activeTab !== 'all' ? { mode: activeTab } : {}),
|
||||
}), [activeTab, isCreatedByMe, searchKeywords, tagIDs])
|
||||
...(category !== 'all' ? { mode: category } : {}),
|
||||
}), [category, debouncedKeywords, isCreatedByMe, tagIDs])
|
||||
|
||||
const {
|
||||
data,
|
||||
@ -177,27 +158,9 @@ const List: FC<Props> = ({
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
|
||||
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchKeywords(keywords)
|
||||
}, { wait: 500 })
|
||||
const handleKeywordsChange = (value: string) => {
|
||||
setKeywords(value)
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const { run: handleTagsUpdate } = useDebounceFn(() => {
|
||||
setTagIDs(tagFilterValue)
|
||||
}, { wait: 500 })
|
||||
const handleTagsChange = (value: string[]) => {
|
||||
setTagFilterValue(value)
|
||||
handleTagsUpdate()
|
||||
}
|
||||
|
||||
const handleCreatedByMeChange = useCallback(() => {
|
||||
const newValue = !isCreatedByMe
|
||||
setIsCreatedByMe(newValue)
|
||||
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
|
||||
}, [isCreatedByMe, setQuery])
|
||||
setIsCreatedByMe(!isCreatedByMe)
|
||||
}, [isCreatedByMe, setIsCreatedByMe])
|
||||
|
||||
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
|
||||
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
|
||||
@ -232,10 +195,10 @@ const List: FC<Props> = ({
|
||||
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-7 pb-5">
|
||||
<TabSliderNew
|
||||
value={activeTab}
|
||||
value={category}
|
||||
onChange={(nextValue) => {
|
||||
if (isAppListCategory(nextValue))
|
||||
setActiveTab(nextValue)
|
||||
setCategory(nextValue)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
@ -246,14 +209,14 @@ const List: FC<Props> = ({
|
||||
{t('showMyCreatedAppsOnly', { ns: 'app' })}
|
||||
</div>
|
||||
</label>
|
||||
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} onOpenTagManagement={() => setShowTagManagementModal(true)} />
|
||||
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="w-[200px]"
|
||||
value={keywords}
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
onClear={() => handleKeywordsChange('')}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onClear={() => setKeywords('')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -267,7 +230,7 @@ const List: FC<Props> = ({
|
||||
ref={newAppCardRef}
|
||||
isLoading={isLoadingCurrentWorkspace}
|
||||
onSuccess={refetch}
|
||||
selectedAppType={activeTab}
|
||||
selectedAppType={category}
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.14.1",
|
||||
"version": "1.14.0",
|
||||
"private": true,
|
||||
"imports": {
|
||||
"#i18n": {
|
||||
|
||||
Reference in New Issue
Block a user