= ({ logs, appDetail, onRefresh }) => {
{endUser}
+ | event.stopPropagation()}>
+
+ |
{isWorkflow && (
diff --git a/web/app/components/apps/__tests__/empty.spec.tsx b/web/app/components/apps/__tests__/empty.spec.tsx
index 8dbbbc3ffb..2536d61006 100644
--- a/web/app/components/apps/__tests__/empty.spec.tsx
+++ b/web/app/components/apps/__tests__/empty.spec.tsx
@@ -2,6 +2,8 @@ import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Empty from '../empty'
+const defaultMessage = 'workflow.tabs.noSnippetsFound'
+
describe('Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -9,32 +11,32 @@ describe('Empty', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
- render()
- expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
+ render()
+ expect(screen.getByText(defaultMessage)).toBeInTheDocument()
})
it('should render 36 placeholder cards', () => {
- const { container } = render()
+ const { container } = render()
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
expect(placeholderCards).toHaveLength(36)
})
- it('should display the no apps found message', () => {
- render()
+ it('should display the provided message', () => {
+ render()
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct container styling for overlay', () => {
- const { container } = render()
+ const { container } = render()
const overlay = container.querySelector('.pointer-events-none')
expect(overlay).toBeInTheDocument()
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
})
it('should have correct styling for placeholder cards', () => {
- const { container } = render()
+ const { container } = render()
const card = container.querySelector('.bg-background-default-lighter')
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
})
@@ -42,10 +44,10 @@ describe('Empty', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
- const { rerender } = render()
- expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
+ const { rerender } = render()
+ expect(screen.getByText(defaultMessage)).toBeInTheDocument()
- rerender()
+ rerender()
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})
diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx
index eddcb31d60..7a421dfa0f 100644
--- a/web/app/components/apps/__tests__/list.spec.tsx
+++ b/web/app/components/apps/__tests__/list.spec.tsx
@@ -1,4 +1,4 @@
-import { act, fireEvent, screen } from '@testing-library/react'
+import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { renderWithNuqs } from '@/test/nuqs-testing'
@@ -15,10 +15,14 @@ vi.mock('@/next/navigation', () => ({
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
+const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
+const mockCanAccessSnippetsAndEvaluation = vi.fn(() => true)
+
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
+ isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
}),
}))
@@ -30,12 +34,21 @@ vi.mock('@/context/global-public-context', () => ({
}),
}))
+vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
+ useSnippetAndEvaluationPlanAccess: () => ({
+ canAccess: mockCanAccessSnippetsAndEvaluation(),
+ isReady: true,
+ }),
+}))
+
const mockSetQuery = vi.fn()
const mockQueryState = {
tagIDs: [] as string[],
+ creatorIDs: [] as string[],
keywords: '',
isCreatedByMe: false,
}
+
vi.mock('../hooks/use-apps-query-state', () => ({
default: () => ({
query: mockQueryState,
@@ -45,6 +58,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
+
vi.mock('../hooks/use-dsl-drag-drop', () => ({
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
mockOnDSLFileDropped = onDSLFileDropped
@@ -54,11 +68,15 @@ vi.mock('../hooks/use-dsl-drag-drop', () => ({
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
+const mockFetchSnippetNextPage = vi.fn()
+const mockUseInfiniteAppList = vi.fn()
+const mockUseInfiniteSnippetList = vi.fn()
const mockServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
+ isFetching: false,
isFetchingNextPage: false,
}
@@ -97,21 +115,85 @@ const defaultAppData = {
}
vi.mock('@/service/use-apps', () => ({
- useInfiniteAppList: () => ({
- data: defaultAppData,
- isLoading: mockServiceState.isLoading,
- isFetchingNextPage: mockServiceState.isFetchingNextPage,
- fetchNextPage: mockFetchNextPage,
- hasNextPage: mockServiceState.hasNextPage,
- error: mockServiceState.error,
- refetch: mockRefetch,
- }),
+ useInfiniteAppList: (params: unknown, options: unknown) => {
+ mockUseInfiniteAppList(params, options)
+ return {
+ data: defaultAppData,
+ isLoading: mockServiceState.isLoading,
+ isFetching: mockServiceState.isFetching,
+ isFetchingNextPage: mockServiceState.isFetchingNextPage,
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: mockServiceState.hasNextPage,
+ error: mockServiceState.error,
+ refetch: mockRefetch,
+ }
+ },
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
+const mockSnippetServiceState = {
+ error: null as Error | null,
+ hasNextPage: false,
+ isLoading: false,
+ isFetching: false,
+ isFetchingNextPage: false,
+}
+
+const defaultSnippetData = {
+ pages: [{
+ data: [
+ {
+ id: 'snippet-1',
+ name: 'Tone Rewriter',
+ description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
+ type: 'node',
+ is_published: false,
+ use_count: 19,
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '🪄',
+ icon_background: '#E0EAFF',
+ icon_url: '',
+ },
+ created_at: 1704067200,
+ updated_at: '2024-01-02 10:00',
+ author: '',
+ },
+ ],
+ total: 1,
+ }],
+}
+
+vi.mock('@/service/use-snippets', () => ({
+ useInfiniteSnippetList: (params: unknown, options: unknown) => {
+ mockUseInfiniteSnippetList(params, options)
+ return {
+ data: defaultSnippetData,
+ isLoading: mockSnippetServiceState.isLoading,
+ isFetching: mockSnippetServiceState.isFetching,
+ isFetchingNextPage: mockSnippetServiceState.isFetchingNextPage,
+ fetchNextPage: mockFetchSnippetNextPage,
+ hasNextPage: mockSnippetServiceState.hasNextPage,
+ error: mockSnippetServiceState.error,
+ }
+ },
+ useCreateSnippetMutation: () => ({
+ mutate: vi.fn(),
+ isPending: false,
+ }),
+ useImportSnippetDSLMutation: () => ({
+ mutate: vi.fn(),
+ isPending: false,
+ }),
+ useConfirmSnippetImportMutation: () => ({
+ mutate: vi.fn(),
+ isPending: false,
+ }),
+}))
+
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
}))
@@ -124,6 +206,17 @@ vi.mock('@/config', async (importOriginal) => {
}
})
+vi.mock('@/service/use-common', () => ({
+ useMembers: () => ({
+ data: {
+ accounts: [
+ { id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' },
+ { id: 'user-2', name: 'Alice', email: 'alice@example.com', avatar: '', avatar_url: '', role: 'admin', last_login_at: '', created_at: '', status: 'active' },
+ ],
+ },
+ }),
+}))
+
vi.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))
@@ -137,13 +230,21 @@ vi.mock('@/next/dynamic', () => ({
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
}
+
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
if (!show)
return null
- return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
+
+ return React.createElement(
+ 'div',
+ { 'data-testid': 'create-dsl-modal' },
+ React.createElement('button', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
+ React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
+ )
}
}
+
return () => null
},
}))
@@ -161,8 +262,8 @@ vi.mock('../new-app-card', () => ({
}))
vi.mock('../empty', () => ({
- default: () => {
- return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
+ default: ({ message }: { message: string }) => {
+ return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, message)
},
}))
@@ -192,151 +293,105 @@ beforeAll(() => {
} as unknown as typeof IntersectionObserver
})
-// Render helper wrapping with shared nuqs testing helper.
-const renderList = (searchParams = '') => {
- return renderWithNuqs( , { searchParams })
+const renderList = (props: React.ComponentProps = {}, searchParams = '') => {
+ return renderWithNuqs( , { searchParams })
}
describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
+ defaultSnippetData.pages[0].data = [
+ {
+ id: 'snippet-1',
+ name: 'Tone Rewriter',
+ description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
+ type: 'node',
+ is_published: false,
+ use_count: 19,
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '🪄',
+ icon_background: '#E0EAFF',
+ icon_url: '',
+ },
+ created_at: 1704067200,
+ updated_at: '2024-01-02 10:00',
+ author: '',
+ },
+ ]
+ defaultSnippetData.pages[0].total = 1
useTagStore.setState({
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
showTagManagementModal: false,
})
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
+ mockIsLoadingCurrentWorkspace.mockReturnValue(false)
+ mockCanAccessSnippetsAndEvaluation.mockReturnValue(true)
mockDragging = false
mockOnDSLFileDropped = null
mockServiceState.error = null
mockServiceState.hasNextPage = false
mockServiceState.isLoading = false
+ mockServiceState.isFetching = false
mockServiceState.isFetchingNextPage = false
mockQueryState.tagIDs = []
+ mockQueryState.creatorIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
+ mockSnippetServiceState.error = null
+ mockSnippetServiceState.hasNextPage = false
+ mockSnippetServiceState.isLoading = false
+ mockSnippetServiceState.isFetching = false
+ mockSnippetServiceState.isFetchingNextPage = false
+ mockUseInfiniteAppList.mockClear()
+ mockUseInfiniteSnippetList.mockClear()
intersectionCallback = null
localStorage.clear()
})
- describe('Rendering', () => {
- it('should render without crashing', () => {
- renderList()
- expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
- })
-
- it('should render tab slider with all app types', () => {
+ describe('Apps Mode', () => {
+ it('should render the apps route switch, dropdown filters, and app cards', () => {
renderList()
- expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
- expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
- expect(screen.getByText('app.types.advanced'))!.toBeInTheDocument()
- expect(screen.getByText('app.types.chatbot'))!.toBeInTheDocument()
- expect(screen.getByText('app.types.agent'))!.toBeInTheDocument()
- expect(screen.getByText('app.types.completion'))!.toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
+ expect(screen.getByRole('link', { name: 'workflow.tabs.snippets' })).toHaveAttribute('href', '/snippets')
+ expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
+ expect(screen.getByText('app.studio.filters.allCreators')).toBeInTheDocument()
+ expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
+ expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
+ expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
+ expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
- it('should render search input', () => {
- renderList()
- expect(screen.getByRole('textbox'))!.toBeInTheDocument()
- })
-
- it('should render tag filter', () => {
- renderList()
- expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
- })
-
- it('should render created by me checkbox', () => {
- renderList()
- expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
- })
-
- it('should render app cards when apps exist', () => {
- renderList()
-
- expect(screen.getByTestId('app-card-app-1'))!.toBeInTheDocument()
- expect(screen.getByTestId('app-card-app-2'))!.toBeInTheDocument()
- })
-
- it('should render new app card for editors', () => {
- renderList()
- expect(screen.getByTestId('new-app-card'))!.toBeInTheDocument()
- })
-
- it('should render footer when branding is disabled', () => {
- renderList()
- expect(screen.getByTestId('footer'))!.toBeInTheDocument()
- })
-
- it('should render drop DSL hint for editors', () => {
- renderList()
- expect(screen.getByText('app.newApp.dropDSLToCreateApp'))!.toBeInTheDocument()
- })
- })
-
- describe('Tab Navigation', () => {
- it('should update URL when workflow tab is clicked', async () => {
+ it('should update the category query when selecting an app type from the dropdown', async () => {
const { onUrlUpdate } = renderList()
- fireEvent.click(screen.getByText('app.types.workflow'))
+ fireEvent.click(screen.getByText('app.studio.filters.types'))
+ fireEvent.click(await screen.findByText('app.types.workflow'))
- await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
- const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
})
- it('should update URL when all tab is clicked', async () => {
- const { onUrlUpdate } = renderList('?category=workflow')
-
- 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)
- })
- })
-
- describe('Search Functionality', () => {
- it('should render search input field', () => {
- renderList()
- expect(screen.getByRole('textbox'))!.toBeInTheDocument()
- })
-
- it('should handle search input change', () => {
+ it('should update creatorIDs when selecting a creator from the dropdown', async () => {
renderList()
- const input = screen.getByRole('textbox')
- fireEvent.change(input, { target: { value: 'test search' } })
+ fireEvent.click(screen.getByText('app.studio.filters.allCreators'))
+ fireEvent.click(await screen.findByText('Current User'))
- expect(mockSetQuery).toHaveBeenCalled()
+ expect(mockSetQuery).toHaveBeenCalledTimes(1)
})
- it('should handle search clear button click', () => {
- mockQueryState.keywords = 'existing search'
+ it('should pass creator_id to the app list query when creatorIDs are selected', () => {
+ mockQueryState.creatorIDs = ['user-1', 'user-2']
renderList()
- const clearButton = document.querySelector('.group')
- expect(clearButton)!.toBeInTheDocument()
- if (clearButton)
- fireEvent.click(clearButton)
-
- expect(mockSetQuery).toHaveBeenCalled()
- })
- })
-
- describe('Tag Filter', () => {
- it('should render tag filter component', () => {
- renderList()
- expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
- })
- })
-
- describe('Created By Me Filter', () => {
- it('should render checkbox with correct label', () => {
- renderList()
- expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
+ expect(mockUseInfiniteAppList).toHaveBeenCalledWith(expect.objectContaining({
+ creator_id: 'user-1,user-2',
+ }), expect.any(Object))
})
it('should handle checkbox change', () => {
@@ -391,39 +446,39 @@ describe('List', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { unmount } = renderWithNuqs( )
- expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
+ expect(screen.getByText('app.types.all')).toBeInTheDocument()
unmount()
renderList()
- expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
+ expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render app cards correctly', () => {
renderList()
- expect(screen.getByText('Test App 1'))!.toBeInTheDocument()
- expect(screen.getByText('Test App 2'))!.toBeInTheDocument()
+ expect(screen.getByText('Test App 1')).toBeInTheDocument()
+ expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
it('should render with all filter options visible', () => {
renderList()
- expect(screen.getByRole('textbox'))!.toBeInTheDocument()
- expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
- expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
+ expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})
describe('Dragging State', () => {
it('should show drop hint when DSL feature is enabled for editors', () => {
renderList()
- expect(screen.getByText('app.newApp.dropDSLToCreateApp'))!.toBeInTheDocument()
+ expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
it('should render dragging state overlay when dragging', () => {
mockDragging = true
const { container } = renderList()
- expect(container)!.toBeInTheDocument()
+ expect(container).toBeInTheDocument()
})
})
@@ -431,12 +486,12 @@ describe('List', () => {
it('should render all app type tabs', () => {
renderList()
- expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
- expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
- expect(screen.getByText('app.types.advanced'))!.toBeInTheDocument()
- expect(screen.getByText('app.types.chatbot'))!.toBeInTheDocument()
- expect(screen.getByText('app.types.agent'))!.toBeInTheDocument()
- expect(screen.getByText('app.types.completion'))!.toBeInTheDocument()
+ expect(screen.getByText('app.types.all')).toBeInTheDocument()
+ expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
+ expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
+ expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
+ expect(screen.getByText('app.types.agent')).toBeInTheDocument()
+ expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should update URL for each app type tab click', async () => {
@@ -454,7 +509,7 @@ describe('List', () => {
onUrlUpdate.mockClear()
fireEvent.click(screen.getByText(text))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
- const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(mode)
}
})
@@ -464,22 +519,22 @@ describe('List', () => {
it('should display all app cards from data', () => {
renderList()
- expect(screen.getByTestId('app-card-app-1'))!.toBeInTheDocument()
- expect(screen.getByTestId('app-card-app-2'))!.toBeInTheDocument()
+ expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
+ expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should display app names correctly', () => {
renderList()
- expect(screen.getByText('Test App 1'))!.toBeInTheDocument()
- expect(screen.getByText('Test App 2'))!.toBeInTheDocument()
+ expect(screen.getByText('Test App 1')).toBeInTheDocument()
+ expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
})
describe('Footer Visibility', () => {
it('should render footer when branding is disabled', () => {
renderList()
- expect(screen.getByTestId('footer'))!.toBeInTheDocument()
+ expect(screen.getByTestId('footer')).toBeInTheDocument()
})
})
@@ -493,99 +548,79 @@ describe('List', () => {
mockOnDSLFileDropped(mockFile)
})
- expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument()
- })
-
- it('should close DSL modal when onClose is called', () => {
- renderList()
-
- const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
- act(() => {
- if (mockOnDSLFileDropped)
- mockOnDSLFileDropped(mockFile)
- })
-
- expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument()
-
+ expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
-
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
- it('should close DSL modal and refetch when onSuccess is called', () => {
+ it('should hide the snippets route switch when snippet access is unavailable', () => {
+ mockCanAccessSnippetsAndEvaluation.mockReturnValue(false)
+
renderList()
- const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
+ expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
+ expect(screen.queryByRole('link', { name: 'workflow.tabs.snippets' })).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Snippets Mode', () => {
+ it('should render the snippets create card and snippet card from the real query hook', () => {
+ renderList({ pageType: 'snippets' })
+
+ expect(screen.getByText('snippet.create')).toBeInTheDocument()
+ expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
+ expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
+ expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
+ })
+
+ it('should request the next snippet page when the infinite-scroll anchor intersects', () => {
+ mockSnippetServiceState.hasNextPage = true
+ renderList({ pageType: 'snippets' })
+
act(() => {
- if (mockOnDSLFileDropped)
- mockOnDSLFileDropped(mockFile)
+ intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
- expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument()
-
- fireEvent.click(screen.getByTestId('success-dsl-modal'))
-
- expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
- expect(mockRefetch).toHaveBeenCalled()
- })
- })
-
- describe('Infinite Scroll', () => {
- it('should call fetchNextPage when intersection observer triggers', () => {
- mockServiceState.hasNextPage = true
- renderList()
-
- if (intersectionCallback) {
- act(() => {
- intersectionCallback!(
- [{ isIntersecting: true } as IntersectionObserverEntry],
- {} as IntersectionObserver,
- )
- })
- }
-
- expect(mockFetchNextPage).toHaveBeenCalled()
+ expect(mockFetchSnippetNextPage).toHaveBeenCalled()
})
- it('should not call fetchNextPage when not intersecting', () => {
- mockServiceState.hasNextPage = true
- renderList()
+ it('should not render app-only controls in snippets mode', () => {
+ renderList({ pageType: 'snippets' })
- if (intersectionCallback) {
- act(() => {
- intersectionCallback!(
- [{ isIntersecting: false } as IntersectionObserverEntry],
- {} as IntersectionObserver,
- )
- })
- }
-
- expect(mockFetchNextPage).not.toHaveBeenCalled()
+ expect(screen.queryByText('app.studio.filters.types')).not.toBeInTheDocument()
+ expect(screen.queryByText('common.tag.placeholder')).not.toBeInTheDocument()
+ expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
})
- it('should not call fetchNextPage when loading', () => {
- mockServiceState.hasNextPage = true
- mockServiceState.isLoading = true
- renderList()
+ it('should pass creator_id to the snippet list query when creatorIDs are selected', () => {
+ mockQueryState.creatorIDs = ['user-1', 'user-2']
- if (intersectionCallback) {
- act(() => {
- intersectionCallback!(
- [{ isIntersecting: true } as IntersectionObserverEntry],
- {} as IntersectionObserver,
- )
- })
- }
+ renderList({ pageType: 'snippets' })
- expect(mockFetchNextPage).not.toHaveBeenCalled()
+ expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith(expect.objectContaining({
+ creator_id: 'user-1,user-2',
+ }), expect.any(Object))
})
- })
- describe('Error State', () => {
- it('should handle error state in useEffect', () => {
- mockServiceState.error = new Error('Test error')
- const { container } = renderList()
- expect(container)!.toBeInTheDocument()
+ it('should not fetch the next snippet page when no more data is available', () => {
+ renderList({ pageType: 'snippets' })
+
+ act(() => {
+ intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
+ })
+
+ expect(mockFetchSnippetNextPage).not.toHaveBeenCalled()
+ })
+
+ it('should reuse the shared empty state when no snippets are available', () => {
+ defaultSnippetData.pages[0].data = []
+ defaultSnippetData.pages[0].total = 0
+
+ renderList({ pageType: 'snippets' })
+
+ expect(screen.getByTestId('empty-state')).toHaveTextContent('workflow.tabs.noSnippetsFound')
})
})
})
diff --git a/web/app/components/apps/app-type-filter-shared.ts b/web/app/components/apps/app-type-filter-shared.ts
new file mode 100644
index 0000000000..26b279ae2f
--- /dev/null
+++ b/web/app/components/apps/app-type-filter-shared.ts
@@ -0,0 +1,16 @@
+import { parseAsStringLiteral } from 'nuqs'
+import { AppModes } from '@/types/app'
+
+const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
+type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
+export type { AppListCategory }
+
+const appListCategorySet = new Set(APP_LIST_CATEGORY_VALUES)
+
+export const isAppListCategory = (value: string): value is AppListCategory => {
+ return appListCategorySet.has(value)
+}
+
+export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
+ .withDefault('all')
+ .withOptions({ history: 'push' })
diff --git a/web/app/components/apps/app-type-filter.tsx b/web/app/components/apps/app-type-filter.tsx
new file mode 100644
index 0000000000..a1401100ae
--- /dev/null
+++ b/web/app/components/apps/app-type-filter.tsx
@@ -0,0 +1,72 @@
+'use client'
+
+import type { AppListCategory } from './app-type-filter-shared'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuRadioItemIndicator,
+ DropdownMenuTrigger,
+} from '@/app/components/base/ui/dropdown-menu'
+import { AppModeEnum } from '@/types/app'
+import { isAppListCategory } from './app-type-filter-shared'
+
+const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
+
+type AppTypeFilterProps = {
+ activeTab: AppListCategory
+ onChange: (value: AppListCategory) => void
+}
+
+const AppTypeFilter = ({
+ activeTab,
+ onChange,
+}: AppTypeFilterProps) => {
+ const { t } = useTranslation()
+
+ const options = useMemo(() => ([
+ { value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
+ { value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
+ { value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
+ { value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
+ { value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
+ { value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
+ ]), [t])
+
+ const activeOption = options.find(option => option.value === activeTab)
+ const triggerLabel = activeTab === 'all' ? t('studio.filters.types', { ns: 'app' }) : activeOption?.text
+
+ return (
+
+
+ )}
+ >
+
+ {triggerLabel}
+
+
+
+ isAppListCategory(value) && onChange(value)}>
+ {options.map(option => (
+
+
+ {option.text}
+
+
+ ))}
+
+
+
+ )
+}
+
+export default AppTypeFilter
diff --git a/web/app/components/apps/creators-filter.tsx b/web/app/components/apps/creators-filter.tsx
new file mode 100644
index 0000000000..9a00ccab6f
--- /dev/null
+++ b/web/app/components/apps/creators-filter.tsx
@@ -0,0 +1,219 @@
+'use client'
+
+import { cn } from '@langgenius/dify-ui/cn'
+import { useCallback, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Checkbox from '@/app/components/base/checkbox'
+import Input from '@/app/components/base/input'
+import { Avatar } from '@/app/components/base/ui/avatar'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from '@/app/components/base/ui/dropdown-menu'
+import { useAppContext } from '@/context/app-context'
+import { useMembers } from '@/service/use-common'
+
+type CreatorsFilterProps = {
+ value: string[]
+ onChange: (value: string[]) => void
+}
+
+type CreatorOption = {
+ id: string
+ name: string
+ avatarUrl: string | null
+ isYou: boolean
+}
+
+const baseChipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
+
+const CreatorsFilter = ({
+ value,
+ onChange,
+}: CreatorsFilterProps) => {
+ const { t } = useTranslation()
+ const { userProfile } = useAppContext()
+ const { data: membersData } = useMembers()
+ const [keywords, setKeywords] = useState('')
+
+ const creatorOptions = useMemo(() => {
+ const currentUserId = userProfile?.id
+ const members = membersData?.accounts ?? []
+
+ return [...members]
+ .filter(member => member.status !== 'pending')
+ .sort((left, right) => {
+ if (left.id === currentUserId)
+ return -1
+ if (right.id === currentUserId)
+ return 1
+ return left.name.localeCompare(right.name)
+ })
+ .map(member => ({
+ id: member.id,
+ name: member.name,
+ avatarUrl: member.avatar_url,
+ isYou: member.id === currentUserId,
+ }))
+ }, [membersData?.accounts, userProfile?.id])
+
+ const filteredCreators = useMemo(() => {
+ const normalizedKeywords = keywords.trim().toLowerCase()
+ if (!normalizedKeywords)
+ return creatorOptions
+
+ return creatorOptions.filter((creator) => {
+ const keyword = normalizedKeywords
+ return creator.name.toLowerCase().includes(keyword)
+ })
+ }, [creatorOptions, keywords])
+
+ const selectedCreators = useMemo(() => {
+ const creatorMap = new Map(creatorOptions.map(creator => [creator.id, creator]))
+ return value
+ .map(id => creatorMap.get(id))
+ .filter((creator): creator is CreatorOption => Boolean(creator))
+ }, [creatorOptions, value])
+
+ const toggleCreator = useCallback((creatorId: string) => {
+ if (value.includes(creatorId)) {
+ onChange(value.filter(id => id !== creatorId))
+ return
+ }
+
+ onChange([...value, creatorId])
+ }, [onChange, value])
+
+ const resetCreators = useCallback(() => {
+ onChange([])
+ setKeywords('')
+ }, [onChange])
+
+ const selectedCount = value.length
+ const selectedAvatarCreators = selectedCreators.slice(0, 3)
+ const isSelected = selectedCount > 0
+
+ return (
+
+
+ )}
+ >
+
+ {!isSelected && (
+ <>
+ {t('studio.filters.allCreators', { ns: 'app' })}
+
+ >
+ )}
+ {isSelected && (
+ <>
+ {t('studio.filters.creators', { ns: 'app' })}
+
+ {selectedAvatarCreators.map((creator, index) => (
+ 0 && '-ml-1',
+ )}
+ />
+ ))}
+
+ {`+${selectedCount}`}
+ {
+ event.stopPropagation()
+ resetCreators()
+ }}
+ onKeyDown={(event) => {
+ if (event.key !== 'Enter' && event.key !== ' ')
+ return
+
+ event.preventDefault()
+ event.stopPropagation()
+ resetCreators()
+ }}
+ >
+
+
+ >
+ )}
+
+
+
+ setKeywords(e.target.value)}
+ onClear={() => setKeywords('')}
+ placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
+ />
+ {isSelected && (
+
+ )}
+
+
+ {filteredCreators.map((creator) => {
+ const checked = value.includes(creator.id)
+
+ return (
+
+ )
+ })}
+
+
+
+ )
+}
+
+export default CreatorsFilter
diff --git a/web/app/components/apps/empty.tsx b/web/app/components/apps/empty.tsx
index 0dee3c908a..0876101d79 100644
--- a/web/app/components/apps/empty.tsx
+++ b/web/app/components/apps/empty.tsx
@@ -1,5 +1,4 @@
import * as React from 'react'
-import { useTranslation } from 'react-i18next'
const DefaultCards = React.memo(() => {
const renderArray = Array.from({ length: 36 })
@@ -17,15 +16,17 @@ const DefaultCards = React.memo(() => {
)
})
-const Empty = () => {
- const { t } = useTranslation()
+type Props = {
+ message: string
+}
+const Empty = ({ message }: Props) => {
return (
<>
- {t('newApp.noAppsFound', { ns: 'app' })}
+ {message}
>
diff --git a/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx b/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx
index 4b0c63f580..d5734cce07 100644
--- a/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx
+++ b/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx
@@ -23,6 +23,7 @@ describe('useAppsQueryState', () => {
const { result } = renderWithAdapter()
expect(result.current.query.tagIDs).toBeUndefined()
+ expect(result.current.query.creatorIDs).toBeUndefined()
expect(result.current.query.keywords).toBeUndefined()
expect(result.current.query.isCreatedByMe).toBe(false)
})
@@ -41,6 +42,12 @@ describe('useAppsQueryState', () => {
expect(result.current.query.keywords).toBe('search term')
})
+ it('should parse creatorIDs when URL includes creatorIDs', () => {
+ const { result } = renderWithAdapter('?creatorIDs=user-1;user-2')
+
+ expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
+ })
+
it('should parse isCreatedByMe when URL includes true value', () => {
const { result } = renderWithAdapter('?isCreatedByMe=true')
@@ -49,10 +56,11 @@ describe('useAppsQueryState', () => {
it('should parse all params when URL includes multiple filters', () => {
const { result } = renderWithAdapter(
- '?tagIDs=tag1;tag2&keywords=test&isCreatedByMe=true',
+ '?tagIDs=tag1;tag2&creatorIDs=user-1;user-2&keywords=test&isCreatedByMe=true',
)
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
+ expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
expect(result.current.query.keywords).toBe('test')
expect(result.current.query.isCreatedByMe).toBe(true)
})
@@ -79,6 +87,16 @@ describe('useAppsQueryState', () => {
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
})
+ it('should update creatorIDs when setQuery receives creatorIDs', () => {
+ const { result } = renderWithAdapter()
+
+ act(() => {
+ result.current.setQuery({ creatorIDs: ['user-1', 'user-2'] })
+ })
+
+ expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
+ })
+
it('should update isCreatedByMe when setQuery receives true', () => {
const { result } = renderWithAdapter()
@@ -131,6 +149,18 @@ describe('useAppsQueryState', () => {
expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2')
})
+ it('should sync creatorIDs to URL when creatorIDs change', async () => {
+ const { result, onUrlUpdate } = renderWithAdapter()
+
+ act(() => {
+ result.current.setQuery({ creatorIDs: ['user-1', 'user-2'] })
+ })
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
+ expect(update.searchParams.get('creatorIDs')).toBe('user-1;user-2')
+ })
+
it('should sync isCreatedByMe to URL when enabled', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
@@ -167,6 +197,18 @@ describe('useAppsQueryState', () => {
expect(update.searchParams.has('tagIDs')).toBe(false)
})
+ it('should remove creatorIDs from URL when creatorIDs are empty', async () => {
+ const { result, onUrlUpdate } = renderWithAdapter('?creatorIDs=user-1;user-2')
+
+ act(() => {
+ result.current.setQuery({ creatorIDs: [] })
+ })
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
+ expect(update.searchParams.has('creatorIDs')).toBe(false)
+ })
+
it('should remove isCreatedByMe from URL when disabled', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
@@ -212,12 +254,17 @@ describe('useAppsQueryState', () => {
result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
})
+ act(() => {
+ result.current.setQuery(prev => ({ ...prev, creatorIDs: ['user-1'] }))
+ })
+
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.creatorIDs).toEqual(['user-1'])
expect(result.current.query.isCreatedByMe).toBe(true)
})
})
diff --git a/web/app/components/apps/hooks/use-apps-query-state.ts b/web/app/components/apps/hooks/use-apps-query-state.ts
index ecf7707e8a..50ae13a425 100644
--- a/web/app/components/apps/hooks/use-apps-query-state.ts
+++ b/web/app/components/apps/hooks/use-apps-query-state.ts
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react'
type AppsQuery = {
tagIDs?: string[]
+ creatorIDs?: string[]
keywords?: string
isCreatedByMe?: boolean
}
@@ -13,6 +14,7 @@ function useAppsQueryState() {
const [urlQuery, setUrlQuery] = useQueryStates(
{
tagIDs: parseAsArrayOf(parseAsString, ';'),
+ creatorIDs: parseAsArrayOf(parseAsString, ';'),
keywords: parseAsString,
isCreatedByMe: parseAsBoolean,
},
@@ -23,15 +25,18 @@ function useAppsQueryState() {
const query = useMemo(() => ({
tagIDs: urlQuery.tagIDs ?? undefined,
+ creatorIDs: urlQuery.creatorIDs ?? undefined,
keywords: normalizeKeywords(urlQuery.keywords),
isCreatedByMe: urlQuery.isCreatedByMe ?? false,
- }), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs])
+ }), [urlQuery.creatorIDs, urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs])
const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => {
const buildPatch = (patch: AppsQuery) => {
const result: Partial = {}
if ('tagIDs' in patch)
result.tagIDs = patch.tagIDs && patch.tagIDs.length > 0 ? patch.tagIDs : null
+ if ('creatorIDs' in patch)
+ result.creatorIDs = patch.creatorIDs && patch.creatorIDs.length > 0 ? patch.creatorIDs : null
if ('keywords' in patch)
result.keywords = patch.keywords ? patch.keywords : null
if ('isCreatedByMe' in patch)
@@ -42,6 +47,7 @@ function useAppsQueryState() {
if (typeof next === 'function') {
setUrlQuery(prev => buildPatch(next({
tagIDs: prev.tagIDs ?? undefined,
+ creatorIDs: prev.creatorIDs ?? undefined,
keywords: normalizeKeywords(prev.keywords),
isCreatedByMe: prev.isCreatedByMe ?? false,
})))
diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx
index 9bf07e81e6..9f23e42bb9 100644
--- a/web/app/components/apps/index.tsx
+++ b/web/app/components/apps/index.tsx
@@ -13,14 +13,24 @@ import { fetchAppDetail } from '@/service/explore'
import { trackCreateApp } from '@/utils/create-app-tracking'
import List from './list'
+export type StudioPageType = 'apps' | 'snippets'
+
+type AppsProps = {
+ pageType?: StudioPageType
+}
+
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
-const Apps = () => {
+const Apps = ({
+ pageType = 'apps',
+}: AppsProps) => {
const { t } = useTranslation()
- useDocumentTitle(t('menus.apps', { ns: 'common' }))
+ useDocumentTitle(pageType === 'apps'
+ ? t('menus.apps', { ns: 'common' })
+ : t('tabs.snippets', { ns: 'workflow' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState(undefined)
@@ -116,7 +126,7 @@ const Apps = () => {
}}
>
-
+
{isShowTryAppPanel && (
import('@/app/components/base/tag-management'), {
ssr: false,
@@ -35,25 +43,18 @@ 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(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
+ pageType?: StudioPageType
}
+
const List: FC = ({
controlRefreshList = 0,
+ pageType = 'apps',
}) => {
const { t } = useTranslation()
+ const isAppsPage = pageType === 'apps'
+ const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const { systemFeatures } = useGlobalPublicStore()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
@@ -62,20 +63,28 @@ const List: FC = ({
parseAsAppListCategory,
)
- const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
- const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
+ const { query: { tagIDs = [], creatorIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [tagFilterValue, setTagFilterValue] = useState(tagIDs)
- const [searchKeywords, setSearchKeywords] = useState(keywords)
- const newAppCardRef = useRef(null)
- const containerRef = useRef(null)
+ const [appKeywords, setAppKeywords] = useState(keywords)
+ const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('')
+ const [snippetKeywords, setSnippetKeywords] = useState('')
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState()
+ const containerRef = useRef(null)
+ const anchorRef = useRef(null)
+ const newAppCardRef = useRef(null)
+
const [workflowOnlineUsersMap, setWorkflowOnlineUsersMap] = useState>({})
const setKeywords = useCallback((keywords: string) => {
setQuery(prev => ({ ...prev, keywords }))
}, [setQuery])
- const setTagIDs = useCallback((tagIDs: string[]) => {
- setQuery(prev => ({ ...prev, tagIDs }))
+
+ const setTagIDs = useCallback((nextTagIDs: string[]) => {
+ setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
+ }, [setQuery])
+
+ const setCreatorIDs = useCallback((nextCreatorIDs: string[]) => {
+ setQuery(prev => ({ ...prev, creatorIDs: nextCreatorIDs }))
}, [setQuery])
const handleDSLFileDropped = useCallback((file: File) => {
@@ -86,15 +95,16 @@ const List: FC = ({
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
containerRef,
- enabled: isCurrentWorkspaceEditor,
+ enabled: isAppsPage && isCurrentWorkspaceEditor,
})
const appListQueryParams = {
page: 1,
limit: 30,
- name: searchKeywords,
+ name: appKeywords,
tag_ids: tagIDs,
- is_created_by_me: isCreatedByMe,
+ is_created_by_me: queryIsCreatedByMe,
+ ...(creatorIDs.length > 0 ? { creator_id: creatorIDs.join(',') } : {}),
...(activeTab !== 'all' ? { mode: activeTab } : {}),
}
@@ -107,84 +117,125 @@ const List: FC = ({
hasNextPage,
error,
refetch,
- } = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
+ } = useInfiniteAppList(appListQueryParams, {
+ enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
+ })
+
+ const {
+ data: snippetData,
+ isLoading: isSnippetListLoading,
+ isFetching: isSnippetListFetching,
+ isFetchingNextPage: isSnippetListFetchingNextPage,
+ fetchNextPage: fetchSnippetNextPage,
+ hasNextPage: hasSnippetNextPage,
+ error: snippetError,
+ } = useInfiniteSnippetList({
+ page: 1,
+ limit: 30,
+ keyword: snippetKeywords || undefined,
+ creator_id: creatorIDs.length > 0 ? creatorIDs.join(',') : undefined,
+ }, {
+ enabled: !isAppsPage,
+ })
useEffect(() => {
- if (controlRefreshList > 0) {
+ if (isAppsPage && controlRefreshList > 0)
refetch()
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [controlRefreshList])
-
- const anchorRef = useRef(null)
- const options = [
- { value: 'all', text: t('types.all', { ns: 'app' }), icon: },
- { value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: },
- { value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: },
- { value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: },
- { value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: },
- { value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: },
- ]
+ }, [controlRefreshList, isAppsPage, refetch])
useEffect(() => {
+ if (!isAppsPage)
+ return
+
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
refetch()
}
- }, [refetch])
+ }, [isAppsPage, refetch])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
- const hasMore = hasNextPage ?? true
+
+ const hasMore = isAppsPage ? (hasNextPage ?? true) : (hasSnippetNextPage ?? true)
+ const isPageLoading = isAppsPage ? isLoading : isSnippetListLoading
+ const isNextPageFetching = isAppsPage ? isFetchingNextPage : isSnippetListFetchingNextPage
+ const currentError = isAppsPage ? error : snippetError
let observer: IntersectionObserver | undefined
- if (error) {
- if (observer)
- observer.disconnect()
+ if (currentError) {
+ observer?.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
- // Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
const containerHeight = containerRef.current.clientHeight
- const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
+ const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
observer = new IntersectionObserver((entries) => {
- if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
- fetchNextPage()
+ if (entries[0].isIntersecting && !isPageLoading && !isNextPageFetching && !currentError && hasMore) {
+ if (isAppsPage)
+ fetchNextPage()
+ else
+ fetchSnippetNextPage()
+ }
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
- threshold: 0.1, // Trigger when 10% of the anchor element is visible
+ threshold: 0.1,
})
observer.observe(anchorRef.current)
}
+
return () => observer?.disconnect()
- }, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
+ }, [error, fetchNextPage, fetchSnippetNextPage, hasNextPage, hasSnippetNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading, isSnippetListFetchingNextPage, isSnippetListLoading, snippetError])
- const { run: handleSearch } = useDebounceFn(() => {
- setSearchKeywords(keywords)
+ const { run: handleAppSearch } = useDebounceFn((value: string) => {
+ setAppKeywords(value)
}, { wait: 500 })
- const handleKeywordsChange = (value: string) => {
- setKeywords(value)
- handleSearch()
- }
- const { run: handleTagsUpdate } = useDebounceFn(() => {
- setTagIDs(tagFilterValue)
+ const { run: handleSnippetSearch } = useDebounceFn((value: string) => {
+ setSnippetKeywords(value)
}, { wait: 500 })
- const handleTagsChange = (value: string[]) => {
+
+ const handleKeywordsChange = useCallback((value: string) => {
+ if (isAppsPage) {
+ setKeywords(value)
+ handleAppSearch(value)
+ return
+ }
+
+ setSnippetKeywordsInput(value)
+ handleSnippetSearch(value)
+ }, [handleAppSearch, handleSnippetSearch, isAppsPage, setKeywords])
+
+ const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
+ setTagIDs(value)
+ }, { wait: 500 })
+
+ const handleTagsChange = useCallback((value: string[]) => {
setTagFilterValue(value)
- handleTagsUpdate()
- }
+ handleTagsUpdate(value)
+ }, [handleTagsUpdate])
- const handleCreatedByMeChange = useCallback(() => {
- const newValue = !isCreatedByMe
- setIsCreatedByMe(newValue)
- setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
- }, [isCreatedByMe, setQuery])
+ const appItems = useMemo(() => {
+ return (data?.pages ?? []).flatMap(({ data: apps }) => apps)
+ }, [data?.pages])
+ const snippetItems = useMemo(() => {
+ return (snippetData?.pages ?? []).flatMap(({ data }) => data)
+ }, [snippetData?.pages])
+
+ const showSkeleton = isAppsPage
+ ? (isLoading || (isFetching && data?.pages?.length === 0))
+ : (isSnippetListLoading || (isSnippetListFetching && snippetItems.length === 0))
+ const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
+ const hasAnySnippet = snippetItems.length > 0
+ const currentKeywords = isAppsPage ? keywords : snippetKeywordsInput
+ const showEmptyState = !showSkeleton && (isAppsPage ? !hasAnyApp : !hasAnySnippet)
+ const emptyStateMessage = isAppsPage
+ ? t('newApp.noAppsFound', { ns: 'app' })
+ : t('tabs.noSnippetsFound', { ns: 'workflow' })
const pages = data?.pages ?? []
const appIds = useMemo(() => {
const ids = new Set()
@@ -233,85 +284,99 @@ const List: FC = ({
return () => window.clearInterval(timer)
}, [refetch, refreshWorkflowOnlineUsers, systemFeatures.enable_collaboration_mode])
- const hasAnyApp = (pages[0]?.total ?? 0) > 0
- // Show skeleton during initial load or when refetching with no previous data
- const showSkeleton = isLoading || (isFetching && pages.length === 0)
-
return (
<>
{dragging && (
-
-
+
)}
- {
- if (isAppListCategory(nextValue))
- setActiveTab(nextValue)
- }}
- options={options}
- />
+
+
+ {isAppsPage && (
+ {
+ void setActiveTab(value)
+ }}
+ />
+ )}
+
+ {isAppsPage && (
+
+ )}
+
+
-
-
handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
+
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
-
+ isAppsPage
+ ? (
+
+ )
+ : canAccessSnippetsAndEvaluation &&
)}
- {(() => {
- if (showSkeleton)
- return
- if (hasAnyApp) {
- return pages.flatMap(({ data: apps }) => apps).map(app => (
-
- ))
- }
+ {showSkeleton && }
- // No apps - show empty state
- return
- })()}
- {isFetchingNextPage && (
+ {!showSkeleton && isAppsPage && hasAnyApp && pages.flatMap(({ data: apps }) => apps).map(app => (
+
+ ))}
+
+ {!showSkeleton && !isAppsPage && hasAnySnippet && snippetItems.map(snippet => (
+
+ ))}
+
+ {showEmptyState && }
+
+ {isAppsPage && isFetchingNextPage && (
+
+ )}
+
+ {!isAppsPage && isSnippetListFetchingNextPage && (
)}
- {isCurrentWorkspaceEditor && (
+ {isAppsPage && isCurrentWorkspaceEditor && (
@@ -319,17 +384,18 @@ const List: FC = ({
{t('newApp.dropDSLToCreateApp', { ns: 'app' })}
)}
+
{!systemFeatures.branding.enabled && (
)}
- {showTagManagementModal && (
+ {isAppsPage && showTagManagementModal && (
)}
- {showCreateFromDSLModal && (
+ {isAppsPage && showCreateFromDSLModal && (
{
diff --git a/web/app/components/apps/studio-route-switch.tsx b/web/app/components/apps/studio-route-switch.tsx
new file mode 100644
index 0000000000..18235f9b74
--- /dev/null
+++ b/web/app/components/apps/studio-route-switch.tsx
@@ -0,0 +1,48 @@
+'use client'
+
+import type { StudioPageType } from '.'
+import { cn } from '@langgenius/dify-ui/cn'
+import Link from '@/next/link'
+
+type Props = {
+ pageType: StudioPageType
+ appsLabel: string
+ snippetsLabel: string
+ showSnippets?: boolean
+}
+
+const StudioRouteSwitch = ({
+ pageType,
+ appsLabel,
+ snippetsLabel,
+ showSnippets = true,
+}: Props) => {
+ return (
+
+
+ {appsLabel}
+
+ {showSnippets && (
+
+ {snippetsLabel}
+
+ )}
+
+ )
+}
+
+export default StudioRouteSwitch
diff --git a/web/app/components/base/audio-gallery/AudioPlayer.tsx b/web/app/components/base/audio-gallery/AudioPlayer.tsx
index c3b2056698..9174b13356 100644
--- a/web/app/components/base/audio-gallery/AudioPlayer.tsx
+++ b/web/app/components/base/audio-gallery/AudioPlayer.tsx
@@ -95,7 +95,7 @@ const AudioPlayer: React.FC = ({ src, srcs }) => {
for (let i = 0; i < samples; i++) {
let sum = 0
for (let j = 0; j < blockSize; j++)
- sum += Math.abs(channelData[i * blockSize + j]!)
+ sum += Math.abs(channelData[i * blockSize + j])
// Apply nonlinear scaling to enhance small amplitudes
waveformData.push((sum / blockSize) * 5)
}
@@ -145,7 +145,7 @@ const AudioPlayer: React.FC = ({ src, srcs }) => {
e.preventDefault()
const getClientX = (event: React.MouseEvent | React.TouchEvent): number => {
if ('touches' in event)
- return event.touches[0]!.clientX
+ return event.touches[0].clientX
return event.clientX
}
const updateProgress = (clientX: number) => {
diff --git a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx
index 83a8666e79..bd5f01bcda 100644
--- a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx
+++ b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx
@@ -151,8 +151,8 @@ describe('ChatWrapper', () => {
render()
- expect(await screen.findByText('Welcome'))!.toBeInTheDocument()
- expect(await screen.findByText('Q1'))!.toBeInTheDocument()
+ expect(await screen.findByText('Welcome')).toBeInTheDocument()
+ expect(await screen.findByText('Q1')).toBeInTheDocument()
fireEvent.click(screen.getByText('Q1'))
expect(handleSend).toHaveBeenCalled()
@@ -170,7 +170,7 @@ describe('ChatWrapper', () => {
} as unknown as ChatHookReturn)
render()
- expect(screen.getByText('Default opening statement'))!.toBeInTheDocument()
+ expect(screen.getByText('Default opening statement')).toBeInTheDocument()
})
it('should render welcome screen without suggested questions', async () => {
@@ -186,7 +186,7 @@ describe('ChatWrapper', () => {
} as unknown as ChatHookReturn)
render()
- expect(await screen.findByText('Welcome message'))!.toBeInTheDocument()
+ expect(await screen.findByText('Welcome message')).toBeInTheDocument()
})
it('should show responding state', async () => {
@@ -197,7 +197,7 @@ describe('ChatWrapper', () => {
} as unknown as ChatHookReturn)
render()
- expect(await screen.findByText('Bot thinking...'))!.toBeInTheDocument()
+ expect(await screen.findByText('Bot thinking...')).toBeInTheDocument()
})
it('should handle manual message input and stop responding', async () => {
@@ -320,9 +320,9 @@ describe('ChatWrapper', () => {
render()
const textboxes = screen.getAllByRole('textbox')
const chatInput = textboxes[textboxes.length - 1]
- const disabledContainer = chatInput!.closest('.pointer-events-none')
- expect(disabledContainer)!.toBeInTheDocument()
- expect(disabledContainer)!.toHaveClass('opacity-50')
+ const disabledContainer = chatInput.closest('.pointer-events-none')
+ expect(disabledContainer).toBeInTheDocument()
+ expect(disabledContainer).toHaveClass('opacity-50')
})
it('should not disable input when required field has value', () => {
@@ -337,7 +337,7 @@ describe('ChatWrapper', () => {
render()
const textboxes = screen.getAllByRole('textbox')
const chatInput = textboxes[textboxes.length - 1]
- const container = chatInput!.closest('.pointer-events-none')
+ const container = chatInput.closest('.pointer-events-none')
expect(container).not.toBeInTheDocument()
})
@@ -361,8 +361,8 @@ describe('ChatWrapper', () => {
render()
const textboxes = screen.getAllByRole('textbox')
const chatInput = textboxes[textboxes.length - 1]
- const container = chatInput!.closest('.pointer-events-none')
- expect(container)!.toBeInTheDocument()
+ const container = chatInput.closest('.pointer-events-none')
+ expect(container).toBeInTheDocument()
})
it('should not disable input when file is fully uploaded', () => {
@@ -411,8 +411,8 @@ describe('ChatWrapper', () => {
render()
const textboxes = screen.getAllByRole('textbox')
const chatInput = textboxes[textboxes.length - 1]
- const container = chatInput!.closest('.pointer-events-none')
- expect(container)!.toBeInTheDocument()
+ const container = chatInput.closest('.pointer-events-none')
+ expect(container).toBeInTheDocument()
})
it('should not disable when all files are uploaded', () => {
@@ -457,7 +457,7 @@ describe('ChatWrapper', () => {
render()
const textarea = screen.getByRole('textbox')
const container = textarea.closest('.pointer-events-none')
- expect(container)!.toBeInTheDocument()
+ expect(container).toBeInTheDocument()
})
it('should not disable input when allInputsHidden is true', () => {
@@ -523,7 +523,7 @@ describe('ChatWrapper', () => {
render()
expect(handleSwitchSibling).toHaveBeenCalledWith('resume-node', expect.any(Object))
- const resumeOptions = handleSwitchSibling.mock.calls[0]![1]
+ const resumeOptions = handleSwitchSibling.mock.calls[0][1]
resumeOptions.onGetSuggestedQuestions('response-from-resume')
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('response-from-resume', 'webApp', 'test-app-id')
})
@@ -619,7 +619,7 @@ describe('ChatWrapper', () => {
render()
- const onStopCallback = vi.mocked(useChat).mock.calls[0]![3] as (taskId: string) => void
+ const onStopCallback = vi.mocked(useChat).mock.calls[0][3] as (taskId: string) => void
onStopCallback('taskId-123')
expect(stopChatMessageResponding).toHaveBeenCalledWith('', 'taskId-123', 'webApp', 'test-app-id')
})
@@ -645,7 +645,7 @@ describe('ChatWrapper', () => {
expect(handleSend).toHaveBeenCalled()
// Get the options passed to handleSend
- const options = handleSend.mock.calls[0]![2]
+ const options = handleSend.mock.calls[0][2]
expect(options.isPublicAPI).toBe(true)
// Call onGetSuggestedQuestions
@@ -679,7 +679,7 @@ describe('ChatWrapper', () => {
fireEvent.click(nextButton)
expect(handleSwitchSibling).toHaveBeenCalled()
- const options = handleSwitchSibling.mock.calls[0]![1]
+ const options = handleSwitchSibling.mock.calls[0][1]
options.onGetSuggestedQuestions('response-id')
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('response-id', 'webApp', 'test-app-id')
}
@@ -708,8 +708,8 @@ describe('ChatWrapper', () => {
expect(handleSend).toHaveBeenCalled()
const args = handleSend.mock.calls[0]
// args[1] is data
- expect(args![1].query).toBe('Q1')
- expect(args![1].parent_message_id).toBeNull()
+ expect(args[1].query).toBe('Q1')
+ expect(args[1].parent_message_id).toBeNull()
}
})
@@ -737,7 +737,7 @@ describe('ChatWrapper', () => {
fireEvent.click(regenerateBtn)
expect(handleSend).toHaveBeenCalled()
const args = handleSend.mock.calls[0]
- expect(args![1].parent_message_id).toBe('a0')
+ expect(args[1].parent_message_id).toBe('a0')
}
})
@@ -774,10 +774,10 @@ describe('ChatWrapper', () => {
} as unknown as ChatHookReturn)
render()
- expect(await screen.findByText('Node 1'))!.toBeInTheDocument()
+ expect(await screen.findByText('Node 1')).toBeInTheDocument()
const input = screen.getAllByRole('textbox').find(el => el.closest('.chat-answer-container')) || screen.getAllByRole('textbox')[0]
- fireEvent.change(input!, { target: { value: 'test' } })
+ fireEvent.change(input, { target: { value: 'test' } })
const runButton = screen.getByText('Run')
fireEvent.click(runButton)
@@ -817,10 +817,10 @@ describe('ChatWrapper', () => {
} as unknown as ChatHookReturn)
render()
- expect(await screen.findByText('Node Web 1'))!.toBeInTheDocument()
+ expect(await screen.findByText('Node Web 1')).toBeInTheDocument()
const input = screen.getAllByRole('textbox').find(el => el.closest('.chat-answer-container')) || screen.getAllByRole('textbox')[0]
- fireEvent.change(input!, { target: { value: 'web-test' } })
+ fireEvent.change(input, { target: { value: 'web-test' } })
fireEvent.click(screen.getByText('Run'))
await waitFor(() => {
@@ -841,7 +841,7 @@ describe('ChatWrapper', () => {
render()
expect(document.querySelector('.chat-answer-container')).not.toBeInTheDocument()
- expect(screen.getByText('Welcome'))!.toBeInTheDocument()
+ expect(screen.getByText('Welcome')).toBeInTheDocument()
})
it('should show all messages including opening statement when there are multiple messages', () => {
@@ -861,7 +861,7 @@ describe('ChatWrapper', () => {
render()
const welcomeElements = screen.getAllByText('Welcome')
expect(welcomeElements.length).toBeGreaterThan(0)
- expect(screen.getByText('User message'))!.toBeInTheDocument()
+ expect(screen.getByText('User message')).toBeInTheDocument()
})
it('should show chatNode and inputs form on desktop for new conversation', () => {
@@ -873,7 +873,7 @@ describe('ChatWrapper', () => {
})
render()
- expect(screen.getByText('Test'))!.toBeInTheDocument()
+ expect(screen.getByText('Test')).toBeInTheDocument()
})
it('should show chatNode on mobile for new conversation only', () => {
@@ -885,7 +885,7 @@ describe('ChatWrapper', () => {
})
const { rerender } = render()
- expect(screen.getByText('Test'))!.toBeInTheDocument()
+ expect(screen.getByText('Test')).toBeInTheDocument()
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
@@ -974,8 +974,8 @@ describe('ChatWrapper', () => {
} as unknown as ChatHookReturn)
render()
- expect(screen.getByText('Answer'))!.toBeInTheDocument()
- expect(screen.getByAltText('answer icon'))!.toBeInTheDocument()
+ expect(screen.getByText('Answer')).toBeInTheDocument()
+ expect(screen.getByAltText('answer icon')).toBeInTheDocument()
})
it('should render question icon fallback when user avatar is available', () => {
@@ -993,7 +993,7 @@ describe('ChatWrapper', () => {
} as unknown as ChatHookReturn)
render()
- expect(screen.getByText('J'))!.toBeInTheDocument()
+ expect(screen.getByText('J')).toBeInTheDocument()
})
it('should use fallback values for nullable appData, appMeta and avatar name', () => {
@@ -1012,8 +1012,8 @@ describe('ChatWrapper', () => {
} as unknown as ChatHookReturn)
render()
- expect(screen.getByText('Question with fallback avatar name'))!.toBeInTheDocument()
- expect(screen.getByText('U'))!.toBeInTheDocument()
+ expect(screen.getByText('Question with fallback avatar name')).toBeInTheDocument()
+ expect(screen.getByText('U')).toBeInTheDocument()
})
it('should set handleStop on currentChatInstanceRef', () => {
@@ -1101,8 +1101,8 @@ describe('ChatWrapper', () => {
render()
const textboxes = screen.getAllByRole('textbox')
const chatInput = textboxes[textboxes.length - 1]
- const container = chatInput!.closest('.pointer-events-none')
- expect(container)!.toBeInTheDocument()
+ const container = chatInput.closest('.pointer-events-none')
+ expect(container).toBeInTheDocument()
})
it('should call formatBooleanInputs when sending message', async () => {
@@ -1223,8 +1223,7 @@ describe('ChatWrapper', () => {
render()
// This tests line 91 - using currentConversationItem.introduction
- // This tests line 91 - using currentConversationItem.introduction
- expect(screen.getByText('Custom introduction from conversation item'))!.toBeInTheDocument()
+ expect(screen.getByText('Custom introduction from conversation item')).toBeInTheDocument()
})
it('should handle early return when hasEmptyInput is already set', () => {
@@ -1243,8 +1242,8 @@ describe('ChatWrapper', () => {
// This tests line 106 - early return when hasEmptyInput is set
const textboxes = screen.getAllByRole('textbox')
const chatInput = textboxes[textboxes.length - 1]
- const container = chatInput!.closest('.pointer-events-none')
- expect(container)!.toBeInTheDocument()
+ const container = chatInput.closest('.pointer-events-none')
+ expect(container).toBeInTheDocument()
})
it('should handle early return when fileIsUploading is already set', () => {
@@ -1271,8 +1270,8 @@ describe('ChatWrapper', () => {
// This tests line 109 - early return when fileIsUploading is set
const textboxes = screen.getAllByRole('textbox')
const chatInput = textboxes[textboxes.length - 1]
- const container = chatInput!.closest('.pointer-events-none')
- expect(container)!.toBeInTheDocument()
+ const container = chatInput.closest('.pointer-events-none')
+ expect(container).toBeInTheDocument()
})
it('should handle doSend with no parent message id', async () => {
@@ -1562,7 +1561,7 @@ describe('ChatWrapper', () => {
} as unknown as ChatHookReturn)
render()
- expect(screen.getByText('Default opening statement'))!.toBeInTheDocument()
+ expect(screen.getByText('Default opening statement')).toBeInTheDocument()
})
it('should handle doSend when regenerating with null parentAnswer', async () => {
@@ -1610,9 +1609,7 @@ describe('ChatWrapper', () => {
// Just verify the component renders - the actual editedQuestion flow
// is tested through the doRegenerate callback that's passed to Chat
- // Just verify the component renders - the actual editedQuestion flow
- // is tested through the doRegenerate callback that's passed to Chat
- expect(screen.getByText('Answer'))!.toBeInTheDocument()
+ expect(screen.getByText('Answer')).toBeInTheDocument()
expect(handleSend).toBeDefined()
})
@@ -1632,9 +1629,7 @@ describe('ChatWrapper', () => {
// The doRegenerate is passed to Chat component and would be called
// This ensures lines 198-200 are covered
- // The doRegenerate is passed to Chat component and would be called
- // This ensures lines 198-200 are covered
- expect(screen.getByText('A1'))!.toBeInTheDocument()
+ expect(screen.getByText('A1')).toBeInTheDocument()
})
it('should handle doRegenerate when question has message_files', async () => {
@@ -1814,38 +1809,7 @@ describe('ChatWrapper', () => {
render()
const textboxes = screen.getAllByRole('textbox')
const chatInput = textboxes[textboxes.length - 1]
- const container = chatInput!.closest('.pointer-events-none')
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
- // Should not be disabled because it's not required
+ const container = chatInput.closest('.pointer-events-none')
// Should not be disabled because it's not required
expect(container).not.toBeInTheDocument()
})
diff --git a/web/app/components/base/chat/chat-with-history/header/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/header/__tests__/index.spec.tsx
index b1c23a129b..5feaccd191 100644
--- a/web/app/components/base/chat/chat-with-history/header/__tests__/index.spec.tsx
+++ b/web/app/components/base/chat/chat-with-history/header/__tests__/index.spec.tsx
@@ -108,7 +108,7 @@ describe('Header Component', () => {
currentConversationItem: mockConv,
sidebarCollapseState: true,
})
- expect(screen.getByText('My Chat'))!.toBeInTheDocument()
+ expect(screen.getByText('My Chat')).toBeInTheDocument()
})
it('should render ViewFormDropdown trigger when inputsForms are present', () => {
@@ -133,7 +133,7 @@ describe('Header Component', () => {
const buttons = screen.getAllByRole('button')
// Sidebar, NewChat, ResetChat (3)
const resetChatBtn = buttons[buttons.length - 1]
- await userEvent.click(resetChatBtn!)
+ await userEvent.click(resetChatBtn)
expect(handleNewConversation).toHaveBeenCalled()
})
@@ -144,7 +144,7 @@ describe('Header Component', () => {
const buttons = screen.getAllByRole('button')
const sidebarBtn = buttons[0]
- await userEvent.click(sidebarBtn!)
+ await userEvent.click(sidebarBtn)
expect(handleSidebarCollapse).toHaveBeenCalledWith(false)
})
@@ -163,7 +163,7 @@ describe('Header Component', () => {
await userEvent.click(trigger)
const pinBtn = await screen.findByText('explore.sidebar.action.pin')
- expect(pinBtn)!.toBeInTheDocument()
+ expect(pinBtn).toBeInTheDocument()
await userEvent.click(pinBtn)
@@ -225,7 +225,7 @@ describe('Header Component', () => {
const renameMenuBtn = await screen.findByText('explore.sidebar.action.rename')
await userEvent.click(renameMenuBtn)
- expect(await screen.findByText('common.chat.renameConversation'))!.toBeInTheDocument()
+ expect(await screen.findByText('common.chat.renameConversation')).toBeInTheDocument()
const input = screen.getByDisplayValue('My Chat')
await userEvent.clear(input)
@@ -236,7 +236,7 @@ describe('Header Component', () => {
expect(handleRenameConversation).toHaveBeenCalledWith('conv-1', 'New Name', expect.any(Object))
- const successCallback = handleRenameConversation.mock.calls[0]![2].onSuccess
+ const successCallback = handleRenameConversation.mock.calls[0][2].onSuccess
await act(async () => {
successCallback()
})
@@ -262,14 +262,14 @@ describe('Header Component', () => {
await userEvent.click(deleteMenuBtn)
expect(handleDeleteConversation).not.toHaveBeenCalled()
- expect(await screen.findByText('share.chat.deleteConversation.title'))!.toBeInTheDocument()
+ expect(await screen.findByText('share.chat.deleteConversation.title')).toBeInTheDocument()
const confirmBtn = await screen.findByText('common.operation.confirm')
await userEvent.click(confirmBtn)
expect(handleDeleteConversation).toHaveBeenCalledWith('conv-1', expect.any(Object))
- const successCallback = handleDeleteConversation.mock.calls[0]![1].onSuccess
+ const successCallback = handleDeleteConversation.mock.calls[0][1].onSuccess
await act(async () => {
successCallback()
})
@@ -311,7 +311,7 @@ describe('Header Component', () => {
await userEvent.click(screen.getByText('My Chat'))
await userEvent.click(await screen.findByText('explore.sidebar.action.delete'))
- expect(await screen.findByText('share.chat.deleteConversation.title'))!.toBeInTheDocument()
+ expect(await screen.findByText('share.chat.deleteConversation.title')).toBeInTheDocument()
})
})
@@ -332,7 +332,7 @@ describe('Header Component', () => {
it('should render system title if conversation id is missing', () => {
setup({ currentConversationId: '', sidebarCollapseState: true })
const titleEl = screen.getByText('Test App')
- expect(titleEl)!.toHaveClass('system-md-semibold')
+ expect(titleEl).toHaveClass('system-md-semibold')
})
it('should render app icon from URL when icon_url is provided', () => {
@@ -347,7 +347,7 @@ describe('Header Component', () => {
},
})
const img = screen.getByAltText('app icon')
- expect(img)!.toHaveAttribute('src', 'https://example.com/icon.png')
+ expect(img).toHaveAttribute('src', 'https://example.com/icon.png')
})
it('should handle undefined appData gracefully (optional chaining)', () => {
@@ -364,8 +364,7 @@ describe('Header Component', () => {
sidebarCollapseState: true,
})
// The separator is just a div with text content '/'
- // The separator is just a div with text content '/'
- expect(screen.getByText('/'))!.toBeInTheDocument()
+ expect(screen.getByText('/')).toBeInTheDocument()
})
it('should handle New Chat button state when currentConversationId is present but isResponding is true', () => {
@@ -378,7 +377,7 @@ describe('Header Component', () => {
const buttons = screen.getAllByRole('button')
// Sidebar, NewChat, ResetChat (3)
const newChatBtn = buttons[1]
- expect(newChatBtn)!.toBeDisabled()
+ expect(newChatBtn).toBeDisabled()
})
it('should handle New Chat button state when currentConversationId is missing and isResponding is false', () => {
@@ -391,7 +390,7 @@ describe('Header Component', () => {
const buttons = screen.getAllByRole('button')
// Sidebar, NewChat (2)
const newChatBtn = buttons[1]
- expect(newChatBtn)!.toBeDisabled()
+ expect(newChatBtn).toBeDisabled()
})
it('should not render operation menu if conversation id is missing', () => {
diff --git a/web/app/components/base/chat/chat-with-history/header/operation.tsx b/web/app/components/base/chat/chat-with-history/header/operation.tsx
index d439a43c1f..a6dd6a0a9e 100644
--- a/web/app/components/base/chat/chat-with-history/header/operation.tsx
+++ b/web/app/components/base/chat/chat-with-history/header/operation.tsx
@@ -71,7 +71,7 @@ const Operation: FC = ({
)}
{isShowDelete && (
handleDeferredAction(onDelete)}
>
diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx
index df261d750c..e6f5657ff5 100644
--- a/web/app/components/base/chat/chat-with-history/hooks.tsx
+++ b/web/app/components/base/chat/chat-with-history/hooks.tsx
@@ -452,7 +452,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
setOriginConversationList(produce((draft) => {
const index = originConversationList.findIndex(item => item.id === conversationId)
- const item = draft[index]!
+ const item = draft[index]
draft[index] = {
...item,
name: newName,
diff --git a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx
index adda03fb55..611d2bb1b9 100644
--- a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx
+++ b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx
@@ -105,7 +105,7 @@ const Operation: FC = ({
)}
{isShowDelete && (
{
e.stopPropagation()
diff --git a/web/app/components/base/chat/chat/citation/popup.tsx b/web/app/components/base/chat/chat/citation/popup.tsx
index 51a73bc4b6..2b4070b69a 100644
--- a/web/app/components/base/chat/chat/citation/popup.tsx
+++ b/web/app/components/base/chat/chat/citation/popup.tsx
@@ -64,10 +64,10 @@ const Popup: FC = ({
-
+
-
+
{(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id
? (
|