From a763aff58ba003339f52574127b9d4ea3dfa5891 Mon Sep 17 00:00:00 2001 From: zxhlyh Date: Fri, 13 Mar 2026 16:12:42 +0800 Subject: [PATCH] feat: snippets list --- web/app/(commonLayout)/role-route-guard.tsx | 2 +- web/app/(commonLayout)/snippets/page.tsx | 7 + .../components/apps/__tests__/list.spec.tsx | 416 +++--------------- .../components/apps/app-type-filter-shared.ts | 15 + web/app/components/apps/app-type-filter.tsx | 71 +++ web/app/components/apps/creators-filter.tsx | 128 ++++++ web/app/components/apps/index.tsx | 16 +- web/app/components/apps/list.tsx | 356 ++++++++++----- web/app/components/header/app-nav/index.tsx | 2 +- web/app/components/header/header-wrapper.tsx | 2 +- web/i18n/en-US/app.json | 14 + 11 files changed, 570 insertions(+), 459 deletions(-) create mode 100644 web/app/(commonLayout)/snippets/page.tsx create mode 100644 web/app/components/apps/app-type-filter-shared.ts create mode 100644 web/app/components/apps/app-type-filter.tsx create mode 100644 web/app/components/apps/creators-filter.tsx diff --git a/web/app/(commonLayout)/role-route-guard.tsx b/web/app/(commonLayout)/role-route-guard.tsx index 1c42be9d15..9ca5b25caa 100644 --- a/web/app/(commonLayout)/role-route-guard.tsx +++ b/web/app/(commonLayout)/role-route-guard.tsx @@ -6,7 +6,7 @@ import { useEffect } from 'react' import Loading from '@/app/components/base/loading' import { useAppContext } from '@/context/app-context' -const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const +const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`) diff --git a/web/app/(commonLayout)/snippets/page.tsx b/web/app/(commonLayout)/snippets/page.tsx new file mode 100644 index 0000000000..660df108c5 --- /dev/null +++ b/web/app/(commonLayout)/snippets/page.tsx @@ -0,0 +1,7 @@ +import Apps from '@/app/components/apps' + +const SnippetsPage = () => { + return +} + +export default SnippetsPage diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 989bf6a788..51c3834791 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' @@ -6,19 +6,15 @@ import { AppModeEnum } from '@/types/app' import List from '../list' -const mockReplace = vi.fn() -const mockRouter = { replace: mockReplace } -vi.mock('next/navigation', () => ({ - useRouter: () => mockRouter, - useSearchParams: () => new URLSearchParams(''), -})) - const mockIsCurrentWorkspaceEditor = vi.fn(() => true) const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false) +const mockIsLoadingCurrentWorkspace = vi.fn(() => false) + vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(), isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(), + isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(), }), })) @@ -36,6 +32,7 @@ const mockQueryState = { keywords: '', isCreatedByMe: false, } + vi.mock('../hooks/use-apps-query-state', () => ({ default: () => ({ query: mockQueryState, @@ -45,6 +42,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 @@ -59,6 +57,7 @@ const mockServiceState = { error: null as Error | null, hasNextPage: false, isLoading: false, + isFetching: false, isFetchingNextPage: false, } @@ -100,6 +99,7 @@ vi.mock('@/service/use-apps', () => ({ useInfiniteAppList: () => ({ data: defaultAppData, isLoading: mockServiceState.isLoading, + isFetching: mockServiceState.isFetching, isFetchingNextPage: mockServiceState.isFetchingNextPage, fetchNextPage: mockFetchNextPage, hasNextPage: mockServiceState.hasNextPage, @@ -133,13 +133,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 }, })) @@ -188,9 +196,8 @@ 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', () => { @@ -202,11 +209,13 @@ describe('List', () => { }) mockIsCurrentWorkspaceEditor.mockReturnValue(true) mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false) + mockIsLoadingCurrentWorkspace.mockReturnValue(false) mockDragging = false mockOnDSLFileDropped = null mockServiceState.error = null mockServiceState.hasNextPage = false mockServiceState.isLoading = false + mockServiceState.isFetching = false mockServiceState.isFetchingNextPage = false mockQueryState.tagIDs = [] mockQueryState.keywords = '' @@ -215,372 +224,93 @@ describe('List', () => { 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() - }) - - it('should render search input', () => { - renderList() - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - it('should render tag filter', () => { - renderList() + 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.creators')).toBeInTheDocument() 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()) + 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') + it('should keep the creators dropdown visual-only and not update app query state', async () => { + renderList() - fireEvent.click(screen.getByText('app.types.all')) + fireEvent.click(screen.getByText('app.studio.filters.creators')) + fireEvent.click(await screen.findByText('Evan')) - 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(mockSetQuery).not.toHaveBeenCalled() + expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument() + }) + + it('should render and close the DSL import modal when a file is dropped', () => { + renderList() + + const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) + act(() => { + if (mockOnDSLFileDropped) + mockOnDSLFileDropped(mockFile) + }) + + expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('close-dsl-modal')) + expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument() }) }) - describe('Search Functionality', () => { - it('should render search input field', () => { - renderList() - expect(screen.getByRole('textbox')).toBeInTheDocument() + describe('Snippets Mode', () => { + it('should render the snippets create card and fake snippet card', () => { + renderList({ pageType: 'snippets' }) + + expect(screen.getByText('app.createSnippet')).toBeInTheDocument() + expect(screen.getByText('app.studio.fakeSnippet.name')).toBeInTheDocument() + expect(screen.getByText('app.studio.fakeSnippet.description')).toBeInTheDocument() + expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument() + expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument() }) - it('should handle search input change', () => { - renderList() + it('should filter local snippets by the search input and show the snippet empty state', () => { + renderList({ pageType: 'snippets' }) const input = screen.getByRole('textbox') - fireEvent.change(input, { target: { value: 'test search' } }) + fireEvent.change(input, { target: { value: 'missing snippet' } }) - expect(mockSetQuery).toHaveBeenCalled() + expect(screen.queryByText('app.studio.fakeSnippet.name')).not.toBeInTheDocument() + expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument() }) - it('should handle search clear button click', () => { - mockQueryState.keywords = 'existing search' + it('should not render app-only controls in snippets mode', () => { + renderList({ pageType: 'snippets' }) - 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(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 handle checkbox change', () => { - renderList() + it('should reserve the infinite-scroll anchor without fetching more pages', () => { + renderList({ pageType: 'snippets' }) - const checkbox = screen.getByTestId('checkbox-undefined') - fireEvent.click(checkbox) - - expect(mockSetQuery).toHaveBeenCalled() - }) - }) - - describe('Non-Editor User', () => { - it('should not render new app card for non-editors', () => { - mockIsCurrentWorkspaceEditor.mockReturnValue(false) - - renderList() - - expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument() - }) - - it('should not render drop DSL hint for non-editors', () => { - mockIsCurrentWorkspaceEditor.mockReturnValue(false) - - renderList() - - expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument() - }) - }) - - describe('Dataset Operator Behavior', () => { - it('should not trigger redirect at component level for dataset operators', () => { - mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true) - - renderList() - - expect(mockReplace).not.toHaveBeenCalled() - }) - }) - - describe('Local Storage Refresh', () => { - it('should call refetch when refresh key is set in localStorage', () => { - localStorage.setItem('needRefreshAppList', '1') - - renderList() - - expect(mockRefetch).toHaveBeenCalled() - expect(localStorage.getItem('needRefreshAppList')).toBeNull() - }) - }) - - describe('Edge Cases', () => { - it('should handle multiple renders without issues', () => { - const { rerender } = renderWithNuqs() - expect(screen.getByText('app.types.all')).toBeInTheDocument() - - rerender() - 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() - }) - - 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() - }) - }) - - describe('Dragging State', () => { - it('should show drop hint when DSL feature is enabled for editors', () => { - renderList() - expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument() - }) - - it('should render dragging state overlay when dragging', () => { - mockDragging = true - const { container } = renderList() - expect(container).toBeInTheDocument() - }) - }) - - describe('App Type Tabs', () => { - 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() - }) - - it('should update URL for each app type tab click', async () => { - const { onUrlUpdate } = renderList() - - const appTypeTexts = [ - { mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' }, - { mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' }, - { mode: AppModeEnum.CHAT, text: 'app.types.chatbot' }, - { mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' }, - { mode: AppModeEnum.COMPLETION, text: 'app.types.completion' }, - ] - - for (const { mode, text } of appTypeTexts) { - onUrlUpdate.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) - } - }) - }) - - describe('App List Display', () => { - 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() - }) - - it('should display app names correctly', () => { - renderList() - - 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() - }) - }) - - describe('DSL File Drop', () => { - it('should handle DSL file drop and show modal', () => { - renderList() - - const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) act(() => { - if (mockOnDSLFileDropped) - mockOnDSLFileDropped(mockFile) + intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver) }) - 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() - - 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', () => { - renderList() - - const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) - act(() => { - if (mockOnDSLFileDropped) - mockOnDSLFileDropped(mockFile) - }) - - 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() - }) - - it('should not call fetchNextPage when not intersecting', () => { - mockServiceState.hasNextPage = true - renderList() - - if (intersectionCallback) { - act(() => { - intersectionCallback!( - [{ isIntersecting: false } as IntersectionObserverEntry], - {} as IntersectionObserver, - ) - }) - } - expect(mockFetchNextPage).not.toHaveBeenCalled() }) - - it('should not call fetchNextPage when loading', () => { - mockServiceState.hasNextPage = true - mockServiceState.isLoading = true - renderList() - - if (intersectionCallback) { - act(() => { - intersectionCallback!( - [{ isIntersecting: true } as IntersectionObserverEntry], - {} as IntersectionObserver, - ) - }) - } - - expect(mockFetchNextPage).not.toHaveBeenCalled() - }) - }) - - describe('Error State', () => { - it('should handle error state in useEffect', () => { - mockServiceState.error = new Error('Test error') - const { container } = renderList() - expect(container).toBeInTheDocument() - }) }) }) 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..2320833f16 --- /dev/null +++ b/web/app/components/apps/app-type-filter-shared.ts @@ -0,0 +1,15 @@ +import { parseAsStringLiteral } from 'nuqs' +import { AppModes } from '@/types/app' + +export const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const +export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number] + +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..72c08e89b9 --- /dev/null +++ b/web/app/components/apps/app-type-filter.tsx @@ -0,0 +1,71 @@ +'use client' + +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 { cn } from '@/utils/classnames' +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: import('./app-type-filter-shared').AppListCategory + onChange: (value: import('./app-type-filter-shared').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..1e2bed5f3a --- /dev/null +++ b/web/app/components/apps/creators-filter.tsx @@ -0,0 +1,128 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Input from '@/app/components/base/input' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuCheckboxItemIndicator, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' +import { cn } from '@/utils/classnames' + +type CreatorOption = { + id: string + name: string + isYou?: boolean + avatarClassName: string +} + +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' + +const creatorOptions: CreatorOption[] = [ + { id: 'evan', name: 'Evan', isYou: true, avatarClassName: 'bg-gradient-to-br from-[#ff9b3f] to-[#ff4d00]' }, + { id: 'jack', name: 'Jack', avatarClassName: 'bg-gradient-to-br from-[#fde68a] to-[#d6d3d1]' }, + { id: 'gigi', name: 'Gigi', avatarClassName: 'bg-gradient-to-br from-[#f9a8d4] to-[#a78bfa]' }, + { id: 'alice', name: 'Alice', avatarClassName: 'bg-gradient-to-br from-[#93c5fd] to-[#4f46e5]' }, + { id: 'mandy', name: 'Mandy', avatarClassName: 'bg-gradient-to-br from-[#374151] to-[#111827]' }, +] + +const CreatorsFilter = () => { + const { t } = useTranslation() + const [selectedCreatorIds, setSelectedCreatorIds] = useState([]) + const [keywords, setKeywords] = useState('') + + const filteredCreators = useMemo(() => { + const normalizedKeywords = keywords.trim().toLowerCase() + if (!normalizedKeywords) + return creatorOptions + + return creatorOptions.filter(creator => creator.name.toLowerCase().includes(normalizedKeywords)) + }, [keywords]) + + const selectedCount = selectedCreatorIds.length + const triggerLabel = selectedCount > 0 + ? `${t('studio.filters.creators', { ns: 'app' })} +${selectedCount}` + : t('studio.filters.creators', { ns: 'app' }) + + const toggleCreator = useCallback((creatorId: string) => { + setSelectedCreatorIds((prev) => { + if (prev.includes(creatorId)) + return prev.filter(id => id !== creatorId) + return [...prev, creatorId] + }) + }, []) + + const resetCreators = useCallback(() => { + setSelectedCreatorIds([]) + setKeywords('') + }, []) + + return ( + + 0 && 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs')} + /> + )} + > + + {triggerLabel} + + + +
+ setKeywords(e.target.value)} + onClear={() => setKeywords('')} + placeholder={t('studio.filters.searchCreators', { ns: 'app' })} + /> + +
+
+ + + {t('studio.filters.allCreators', { ns: 'app' })} + + + + {filteredCreators.map(creator => ( + toggleCreator(creator.id)} + > + + + {creator.name} + {creator.isYou && ( + {t('studio.filters.you', { ns: 'app' })} + )} + + + + ))} +
+
+
+ ) +} + +export default CreatorsFilter diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index dce9de190d..6ef3adc87b 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -14,10 +14,20 @@ import CreateAppModal from '../explore/create-app-modal' import TryApp from '../explore/try-app' import List from './list' -const Apps = () => { +export type StudioPageType = 'apps' | 'snippets' + +type AppsProps = { + pageType?: StudioPageType +} + +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) @@ -101,7 +111,7 @@ const Apps = () => { }} >
- + {isShowTryAppPanel && ( 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) +type StudioSnippet = { + id: string + name: string + description: string + author: string + updatedAt: string + usage: string + icon: string + status?: string } -const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES) - .withDefault('all') - .withOptions({ history: 'push' }) +const StudioRouteSwitch = ({ pageType, appsLabel, snippetsLabel }: { pageType: StudioPageType, appsLabel: string, snippetsLabel: string }) => { + return ( +
+ + {appsLabel} + + + {snippetsLabel} + +
+ ) +} + +const SnippetCreateCard = () => { + const { t } = useTranslation() + + return ( +
+
+
{t('createSnippet', { ns: 'app' })}
+
+ + {t('newApp.startFromBlank', { ns: 'app' })} +
+
+ + {t('importDSL', { ns: 'app' })} +
+
+
+ ) +} + +const SnippetCard = ({ + snippet, +}: { + snippet: StudioSnippet +}) => { + return ( +
+ {snippet.status && ( +
+ {snippet.status} +
+ )} +
+
+ {snippet.icon} +
+
+
+ {snippet.name} +
+
+
+
+
+ {snippet.description} +
+
+
+ {snippet.author} + · + {snippet.updatedAt} + · + {snippet.usage} +
+
+ ) +} type Props = { controlRefreshList?: number + pageType?: StudioPageType } + const List: FC = ({ controlRefreshList = 0, + pageType = 'apps', }) => { const { t } = useTranslation() + const isAppsPage = pageType === 'apps' const { systemFeatures } = useGlobalPublicStore() const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) @@ -61,18 +152,21 @@ const List: FC = ({ ) const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState() - const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe) const [tagFilterValue, setTagFilterValue] = useState(tagIDs) - const [searchKeywords, setSearchKeywords] = useState(keywords) - const newAppCardRef = useRef(null) - const containerRef = useRef(null) + const [appKeywords, setAppKeywords] = useState(keywords) + const [snippetKeywords, setSnippetKeywords] = useState('') const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [droppedDSLFile, setDroppedDSLFile] = useState() - const setKeywords = useCallback((keywords: string) => { - setQuery(prev => ({ ...prev, keywords })) + const containerRef = useRef(null) + const anchorRef = useRef(null) + const newAppCardRef = useRef(null) + + const setKeywords = useCallback((nextKeywords: string) => { + setQuery(prev => ({ ...prev, keywords: nextKeywords })) }, [setQuery]) - const setTagIDs = useCallback((tagIDs: string[]) => { - setQuery(prev => ({ ...prev, tagIDs })) + + const setTagIDs = useCallback((nextTagIDs: string[]) => { + setQuery(prev => ({ ...prev, tagIDs: nextTagIDs })) }, [setQuery]) const handleDSLFileDropped = useCallback((file: File) => { @@ -83,15 +177,15 @@ 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, ...(activeTab !== 'all' ? { mode: activeTab } : {}), } @@ -104,48 +198,40 @@ const List: FC = ({ hasNextPage, error, refetch, - } = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator }) + } = useInfiniteAppList(appListQueryParams, { + enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator, + }) 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) : false let observer: IntersectionObserver | undefined if (error) { - if (observer) - observer.disconnect() + 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) @@ -153,110 +239,159 @@ const List: FC = ({ }, { 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, hasNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading]) - 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 handleKeywordsChange = useCallback((value: string) => { + if (isAppsPage) { + setKeywords(value) + handleAppSearch(value) + return + } + + setSnippetKeywords(value) + }, [handleAppSearch, isAppsPage, setKeywords]) + + const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => { + setTagIDs(value) }, { wait: 500 }) - const handleTagsChange = (value: string[]) => { + + 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 pages = data?.pages ?? [] - 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) + const snippetItems = useMemo(() => ([ + { + id: 'snippet-1', + name: t('studio.fakeSnippet.name', { ns: 'app' }), + description: t('studio.fakeSnippet.description', { ns: 'app' }), + author: t('studio.fakeSnippet.author', { ns: 'app' }), + updatedAt: t('studio.fakeSnippet.updatedAt', { ns: 'app' }), + usage: t('studio.fakeSnippet.usage', { ns: 'app' }), + icon: '🪄', + status: t('studio.fakeSnippet.status', { ns: 'app' }), + }, + ]), [t]) + + const filteredSnippetItems = useMemo(() => { + const normalizedKeywords = snippetKeywords.trim().toLowerCase() + if (!normalizedKeywords) + return snippetItems + + return snippetItems.filter(item => + item.name.toLowerCase().includes(normalizedKeywords) + || item.description.toLowerCase().includes(normalizedKeywords), + ) + }, [snippetItems, snippetKeywords]) + + const showSkeleton = isAppsPage && (isLoading || (isFetching && data?.pages?.length === 0)) + const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0 + const hasAnySnippet = filteredSnippetItems.length > 0 + const currentKeywords = isAppsPage ? keywords : snippetKeywords return ( <>
{dragging && ( -
-
+
)}
- { - if (isAppListCategory(nextValue)) - setActiveTab(nextValue) - }} - options={options} - /> -
- + - + {isAppsPage && ( + { + void setActiveTab(value) + }} + /> + )} + + {isAppsPage && ( + + )} +
+ +
handleKeywordsChange(e.target.value)} onClear={() => handleKeywordsChange('')} />
+
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && ( - + isAppsPage + ? ( + + ) + : )} - {(() => { - if (showSkeleton) - return - if (hasAnyApp) { - return pages.flatMap(({ data: apps }) => apps).map(app => ( - - )) - } + {showSkeleton && } - // No apps - show empty state - return - })()} - {isFetchingNextPage && ( + {!showSkeleton && isAppsPage && hasAnyApp && appItems.map(app => ( + + ))} + + {!showSkeleton && !isAppsPage && hasAnySnippet && filteredSnippetItems.map(snippet => ( + + ))} + + {!showSkeleton && isAppsPage && !hasAnyApp && } + + {!showSkeleton && !isAppsPage && !hasAnySnippet && ( +
+ {t('tabs.noSnippetsFound', { ns: 'workflow' })} +
+ )} + + {isAppsPage && isFetchingNextPage && ( )}
- {isCurrentWorkspaceEditor && ( + {isAppsPage && isCurrentWorkspaceEditor && (
@@ -264,17 +399,18 @@ const List: FC = ({ {t('newApp.dropDSLToCreateApp', { ns: 'app' })}
)} + {!systemFeatures.branding.enabled && (
)}
- {showTagManagementModal && ( + {isAppsPage && showTagManagementModal && ( )}
- {showCreateFromDSLModal && ( + {isAppsPage && showCreateFromDSLModal && ( { diff --git a/web/app/components/header/app-nav/index.tsx b/web/app/components/header/app-nav/index.tsx index 737dd96bab..11a01d02ab 100644 --- a/web/app/components/header/app-nav/index.tsx +++ b/web/app/components/header/app-nav/index.tsx @@ -105,7 +105,7 @@ const AppNav = () => { icon={} activeIcon={} text={t('menus.apps', { ns: 'common' })} - activeSegment={['apps', 'app']} + activeSegment={['apps', 'app', 'snippets']} link="/apps" curNav={appDetail} navigationItems={navItems} diff --git a/web/app/components/header/header-wrapper.tsx b/web/app/components/header/header-wrapper.tsx index 1b81c1152c..bf24da0107 100644 --- a/web/app/components/header/header-wrapper.tsx +++ b/web/app/components/header/header-wrapper.tsx @@ -14,7 +14,7 @@ const HeaderWrapper = ({ children, }: HeaderWrapperProps) => { const pathname = usePathname() - const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname) + const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname) // Check if the current path is a workflow canvas & fullscreen const inWorkflowCanvas = pathname.endsWith('/workflow') const isPipelineCanvas = pathname.endsWith('/pipeline') diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index e4109db4b6..afbd352ddf 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -35,6 +35,7 @@ "communityIntro": "Discuss with team members, contributors and developers on different channels.", "createApp": "CREATE APP", "createFromConfigFile": "Create from DSL file", + "createSnippet": "CREATE SNIPPET", "deleteAppConfirmContent": "Deleting the app is irreversible. Users will no longer be able to access your app, and all prompt configurations and logs will be permanently deleted.", "deleteAppConfirmTitle": "Delete this app?", "dslUploader.browse": "Browse", @@ -208,6 +209,19 @@ "structOutput.required": "Required", "structOutput.structured": "Structured", "structOutput.structuredTip": "Structured Outputs is a feature that ensures the model will always generate responses that adhere to your supplied JSON Schema", + "studio.apps": "Apps", + "studio.fakeSnippet.author": "Evan", + "studio.fakeSnippet.description": "Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.", + "studio.fakeSnippet.name": "Tone Rewriter", + "studio.fakeSnippet.status": "Draft", + "studio.fakeSnippet.updatedAt": "Updated 2h ago", + "studio.fakeSnippet.usage": "Used 19 times", + "studio.filters.allCreators": "All creators", + "studio.filters.creators": "Creators", + "studio.filters.reset": "Reset", + "studio.filters.searchCreators": "Search creator...", + "studio.filters.types": "Types", + "studio.filters.you": "You", "switch": "Switch to Workflow Orchestrate", "switchLabel": "The app copy to be created", "switchStart": "Start switch",