From e51af66d95c6b374efff8054e54b63c8061ddca7 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Sun, 12 Apr 2026 12:19:17 +0800 Subject: [PATCH] feat(web): support creator filtering in apps & snippets --- .../apps/app-list-browsing-flow.test.tsx | 50 ++-- web/__tests__/apps/create-app-flow.test.tsx | 29 +++ .../components/apps/__tests__/list.spec.tsx | 92 +++++-- web/app/components/apps/creators-filter.tsx | 229 ++++++++++++------ .../__tests__/use-apps-query-state.spec.tsx | 49 +++- .../apps/hooks/use-apps-query-state.ts | 8 +- web/app/components/apps/list.tsx | 16 +- web/contract/console/snippets.ts | 1 + web/service/use-apps.ts | 3 + web/service/use-snippets.ts | 2 + 10 files changed, 364 insertions(+), 115 deletions(-) diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index 1be7e56086..60299631d3 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -77,6 +77,13 @@ vi.mock('@/context/provider-context', () => ({ }), })) +vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({ + useSnippetAndEvaluationPlanAccess: () => ({ + canAccess: true, + isReady: true, + }), +})) + vi.mock('@/app/components/base/tag-management/store', () => ({ useStore: (selector: (state: Record) => unknown) => { const state = { @@ -93,6 +100,16 @@ vi.mock('@/service/tag', () => ({ fetchTagList: vi.fn().mockResolvedValue([]), })) +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' }, + ], + }, + }), +})) + vi.mock('@/service/use-apps', () => ({ useInfiniteAppList: () => ({ data: { pages: mockPages }, @@ -110,6 +127,18 @@ vi.mock('@/service/use-apps', () => ({ }), })) +vi.mock('@/service/use-snippets', () => ({ + useInfiniteSnippetList: () => ({ + data: { pages: [] }, + isLoading: false, + isFetching: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + hasNextPage: false, + error: null, + }), +})) + vi.mock('@/hooks/use-pay', () => ({ CheckModal: () => null, })) @@ -319,16 +348,11 @@ describe('App List Browsing Flow', () => { // -- Tab navigation -- describe('Tab Navigation', () => { - it('should render all category tabs', () => { + it('should render the app type dropdown trigger', () => { mockPages = [createPage([createMockApp()])] 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.studio.filters.types')).toBeInTheDocument() }) }) @@ -354,21 +378,19 @@ describe('App List Browsing Flow', () => { // -- "Created by me" filter -- describe('Created By Me Filter', () => { - it('should render the "created by me" checkbox', () => { + it('should not render a standalone "created by me" checkbox in the current header layout', () => { mockPages = [createPage([createMockApp()])] renderList() - expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() + expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument() }) - it('should toggle the "created by me" filter on click', () => { + it('should keep the current layout stable without a "created by me" control', () => { mockPages = [createPage([createMockApp()])] renderList() - const checkbox = screen.getByText('app.showMyCreatedAppsOnly') - fireEvent.click(checkbox) - - expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() + expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument() + expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument() }) }) diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index bc1f7a3a06..58915ac414 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -64,6 +64,13 @@ vi.mock('@/context/provider-context', () => ({ }), })) +vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({ + useSnippetAndEvaluationPlanAccess: () => ({ + canAccess: true, + isReady: true, + }), +})) + vi.mock('@/app/components/base/tag-management/store', () => ({ useStore: (selector: (state: Record) => unknown) => { const state = { @@ -80,6 +87,16 @@ vi.mock('@/service/tag', () => ({ fetchTagList: vi.fn().mockResolvedValue([]), })) +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' }, + ], + }, + }), +})) + vi.mock('@/service/use-apps', () => ({ useInfiniteAppList: () => ({ data: { pages: mockPages }, @@ -97,6 +114,18 @@ vi.mock('@/service/use-apps', () => ({ }), })) +vi.mock('@/service/use-snippets', () => ({ + useInfiniteSnippetList: () => ({ + data: { pages: [] }, + isLoading: false, + isFetching: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + hasNextPage: false, + error: null, + }), +})) + vi.mock('@/hooks/use-pay', () => ({ CheckModal: () => null, })) diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index feb1519e57..b316843e70 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -44,6 +44,7 @@ vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({ const mockSetQuery = vi.fn() const mockQueryState = { tagIDs: [] as string[], + creatorIDs: [] as string[], keywords: '', isCreatedByMe: false, } @@ -68,6 +69,8 @@ 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, @@ -112,16 +115,19 @@ const defaultAppData = { } vi.mock('@/service/use-apps', () => ({ - useInfiniteAppList: () => ({ - data: defaultAppData, - isLoading: mockServiceState.isLoading, - isFetching: mockServiceState.isFetching, - 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, @@ -162,15 +168,18 @@ const defaultSnippetData = { } vi.mock('@/service/use-snippets', () => ({ - useInfiniteSnippetList: () => ({ - data: defaultSnippetData, - isLoading: mockSnippetServiceState.isLoading, - isFetching: mockSnippetServiceState.isFetching, - isFetchingNextPage: mockSnippetServiceState.isFetchingNextPage, - fetchNextPage: mockFetchSnippetNextPage, - hasNextPage: mockSnippetServiceState.hasNextPage, - error: mockSnippetServiceState.error, - }), + 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, @@ -193,6 +202,17 @@ vi.mock('@/config', () => ({ NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList', })) +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, })) @@ -292,6 +312,7 @@ describe('List', () => { mockServiceState.isFetching = false mockServiceState.isFetchingNextPage = false mockQueryState.tagIDs = [] + mockQueryState.creatorIDs = [] mockQueryState.keywords = '' mockQueryState.isCreatedByMe = false mockSnippetServiceState.error = null @@ -299,6 +320,8 @@ describe('List', () => { mockSnippetServiceState.isLoading = false mockSnippetServiceState.isFetching = false mockSnippetServiceState.isFetchingNextPage = false + mockUseInfiniteAppList.mockClear() + mockUseInfiniteSnippetList.mockClear() intersectionCallback = null localStorage.clear() }) @@ -310,7 +333,7 @@ describe('List', () => { 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('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() @@ -328,14 +351,23 @@ describe('List', () => { expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW) }) - it('should keep the creators dropdown visual-only and not update app query state', async () => { + it('should update creatorIDs when selecting a creator from the dropdown', async () => { renderList() - fireEvent.click(screen.getByText('app.studio.filters.creators')) - fireEvent.click(await screen.findByText('Evan')) + fireEvent.click(screen.getByText('app.studio.filters.allCreators')) + fireEvent.click(await screen.findByText('Current User')) - expect(mockSetQuery).not.toHaveBeenCalled() - expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument() + expect(mockSetQuery).toHaveBeenCalledTimes(1) + }) + + it('should pass creator_id to the app list query when creatorIDs are selected', () => { + mockQueryState.creatorIDs = ['user-1', 'user-2'] + + renderList() + + expect(mockUseInfiniteAppList).toHaveBeenCalledWith(expect.objectContaining({ + creator_id: 'user-1,user-2', + }), expect.any(Object)) }) it('should render and close the DSL import modal when a file is dropped', () => { @@ -393,6 +425,16 @@ describe('List', () => { expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument() }) + it('should pass creator_id to the snippet list query when creatorIDs are selected', () => { + mockQueryState.creatorIDs = ['user-1', 'user-2'] + + renderList({ pageType: 'snippets' }) + + expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith(expect.objectContaining({ + creator_id: 'user-1,user-2', + }), expect.any(Object)) + }) + it('should not fetch the next snippet page when no more data is available', () => { renderList({ pageType: 'snippets' }) diff --git a/web/app/components/apps/creators-filter.tsx b/web/app/components/apps/creators-filter.tsx index 1e2bed5f3a..7a268c5136 100644 --- a/web/app/components/apps/creators-filter.tsx +++ b/web/app/components/apps/creators-filter.tsx @@ -2,81 +2,162 @@ 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, - DropdownMenuCheckboxItem, - DropdownMenuCheckboxItemIndicator, DropdownMenuContent, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@/app/components/base/ui/dropdown-menu' +import { useAppContext } from '@/context/app-context' +import { useMembers } from '@/service/use-common' import { cn } from '@/utils/classnames' +type CreatorsFilterProps = { + value: string[] + onChange: (value: string[]) => void +} + type CreatorOption = { id: string name: string - isYou?: boolean - avatarClassName: string + avatarUrl: string | null + isYou: boolean } -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 baseChipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors' -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 CreatorsFilter = ({ + value, + onChange, +}: CreatorsFilterProps) => { const { t } = useTranslation() - const [selectedCreatorIds, setSelectedCreatorIds] = useState([]) + 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 => creator.name.toLowerCase().includes(normalizedKeywords)) - }, [keywords]) + return creatorOptions.filter((creator) => { + const keyword = normalizedKeywords + return creator.name.toLowerCase().includes(keyword) + }) + }, [creatorOptions, keywords]) - const selectedCount = selectedCreatorIds.length - const triggerLabel = selectedCount > 0 - ? `${t('studio.filters.creators', { ns: 'app' })} +${selectedCount}` - : t('studio.filters.creators', { ns: 'app' }) + 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) => { - setSelectedCreatorIds((prev) => { - if (prev.includes(creatorId)) - return prev.filter(id => id !== creatorId) - return [...prev, creatorId] - }) - }, []) + if (value.includes(creatorId)) { + onChange(value.filter(id => id !== creatorId)) + return + } + + onChange([...value, creatorId]) + }, [onChange, value]) const resetCreators = useCallback(() => { - setSelectedCreatorIds([]) + onChange([]) setKeywords('') - }, []) + }, [onChange]) + + const selectedCount = value.length + const selectedAvatarCreators = selectedCreators.slice(0, 3) + const isSelected = selectedCount > 0 return ( 0 && 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs')} +
)} > - {triggerLabel} - + {!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() + }} + > + + + + )} -
+
{ onClear={() => setKeywords('')} placeholder={t('studio.filters.searchCreators', { ns: 'app' })} /> - -
-
- - - {t('studio.filters.allCreators', { ns: 'app' })} - - - - {filteredCreators.map(creator => ( - toggleCreator(creator.id)} + {isSelected && ( + + )} +
+
+ {filteredCreators.map((creator) => { + const checked = value.includes(creator.id) + + return ( + + ) + })}
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 8e8e5821a8..29e75ec605 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/list.tsx b/web/app/components/apps/list.tsx index a0cfb653ad..47dcbf34ad 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -60,7 +60,7 @@ const List: FC = ({ parseAsAppListCategory, ) - const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState() + const { query: { tagIDs = [], creatorIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState() const [tagFilterValue, setTagFilterValue] = useState(tagIDs) const [appKeywords, setAppKeywords] = useState(keywords) const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('') @@ -79,6 +79,10 @@ const List: FC = ({ setQuery(prev => ({ ...prev, tagIDs: nextTagIDs })) }, [setQuery]) + const setCreatorIDs = useCallback((nextCreatorIDs: string[]) => { + setQuery(prev => ({ ...prev, creatorIDs: nextCreatorIDs })) + }, [setQuery]) + const handleDSLFileDropped = useCallback((file: File) => { setDroppedDSLFile(file) setShowCreateFromDSLModal(true) @@ -96,6 +100,7 @@ const List: FC = ({ name: appKeywords, tag_ids: tagIDs, is_created_by_me: queryIsCreatedByMe, + ...(creatorIDs.length > 0 ? { creator_id: creatorIDs.join(',') } : {}), ...(activeTab !== 'all' ? { mode: activeTab } : {}), } @@ -124,6 +129,7 @@ const List: FC = ({ page: 1, limit: 30, keyword: snippetKeywords || undefined, + creator_id: creatorIDs.length > 0 ? creatorIDs.join(',') : undefined, }, { enabled: !isAppsPage, }) @@ -227,10 +233,10 @@ const List: FC = ({ <>
{dragging && ( -
+
)} -
+
= ({ }} /> )} - + {isAppsPage && ( )} @@ -266,7 +272,7 @@ const List: FC = ({
diff --git a/web/contract/console/snippets.ts b/web/contract/console/snippets.ts index 82a5838436..7fd096aa3a 100644 --- a/web/contract/console/snippets.ts +++ b/web/contract/console/snippets.ts @@ -32,6 +32,7 @@ export const listCustomizedSnippetsContract = base page: number limit: number keyword?: string + creator_id?: string is_published?: boolean } }>()) diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts index 96a708e3ec..7a49ea604f 100644 --- a/web/service/use-apps.ts +++ b/web/service/use-apps.ts @@ -30,6 +30,7 @@ type AppListParams = { name?: string mode?: AppModeEnum | 'all' tag_ids?: string[] + creator_id?: string is_created_by_me?: boolean } @@ -55,6 +56,7 @@ const normalizeAppListParams = (params: AppListParams) => { name = '', mode, tag_ids, + creator_id, is_created_by_me, } = params @@ -66,6 +68,7 @@ const normalizeAppListParams = (params: AppListParams) => { name, ...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}), ...(tag_ids?.length ? { tag_ids } : {}), + ...(creator_id ? { creator_id } : {}), ...(is_created_by_me ? { is_created_by_me } : {}), } } diff --git a/web/service/use-snippets.ts b/web/service/use-snippets.ts index cb8056921f..dffc3d6631 100644 --- a/web/service/use-snippets.ts +++ b/web/service/use-snippets.ts @@ -25,6 +25,7 @@ type SnippetListParams = { page?: number limit?: number keyword?: string + creator_id?: string is_published?: boolean } @@ -123,6 +124,7 @@ const normalizeSnippetListParams = (params: SnippetListParams) => { page: params.page ?? DEFAULT_SNIPPET_LIST_PARAMS.page, limit: params.limit ?? DEFAULT_SNIPPET_LIST_PARAMS.limit, ...(params.keyword ? { keyword: params.keyword } : {}), + ...(params.creator_id ? { creator_id: params.creator_id } : {}), ...(typeof params.is_published === 'boolean' ? { is_published: params.is_published } : {}), } }